python-mobius 0.10.1__tar.gz → 0.10.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. {python_mobius-0.10.1 → python_mobius-0.10.3}/CHANGELOG.md +9 -0
  2. {python_mobius-0.10.1 → python_mobius-0.10.3}/PKG-INFO +1 -1
  3. {python_mobius-0.10.1 → python_mobius-0.10.3}/pyproject.toml +1 -1
  4. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/__init__.py +1 -1
  5. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/device.py +67 -9
  6. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_networked_thread_devices.py +65 -0
  7. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_relay.py +14 -0
  8. {python_mobius-0.10.1 → python_mobius-0.10.3}/.forgejo/scripts/extract_changelog_section.py +0 -0
  9. {python_mobius-0.10.1 → python_mobius-0.10.3}/.forgejo/workflows/release.yml +0 -0
  10. {python_mobius-0.10.1 → python_mobius-0.10.3}/.gitignore +0 -0
  11. {python_mobius-0.10.1 → python_mobius-0.10.3}/LICENSE +0 -0
  12. {python_mobius-0.10.1 → python_mobius-0.10.3}/README.md +0 -0
  13. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/00-overview.md +0 -0
  14. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/01-ble-transport.md +0 -0
  15. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/02-framing-and-crc.md +0 -0
  16. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/03-attributes-and-opcodes.md +0 -0
  17. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/04-device-identity.md +0 -0
  18. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/05-scenes.md +0 -0
  19. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/06-light-schedule.md +0 -0
  20. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/07-pump-schedule.md +0 -0
  21. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/08-manufacturer-data.md +0 -0
  22. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/09-thread-coap-relay.md +0 -0
  23. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/10-known-gaps-and-open-questions.md +0 -0
  24. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/11-device-status-attributes.md +0 -0
  25. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/12-device-identity-and-address-stability.md +0 -0
  26. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/13-attribute-dump.md +0 -0
  27. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/14-attribute-reference.md +0 -0
  28. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/15-mob-template-format.md +0 -0
  29. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/16-python-library.md +0 -0
  30. {python_mobius-0.10.1 → python_mobius-0.10.3}/documentation/17-configuration-checksums.md +0 -0
  31. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/checksums.py +0 -0
  32. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/cli.py +0 -0
  33. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/coap.py +0 -0
  34. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/constants.py +0 -0
  35. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/crc.py +0 -0
  36. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/device_status.py +0 -0
  37. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/discovery.py +0 -0
  38. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/dump.py +0 -0
  39. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/frame.py +0 -0
  40. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/manufacturer.py +0 -0
  41. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/mesh_address.py +0 -0
  42. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/mob.py +0 -0
  43. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/modifiers.py +0 -0
  44. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/power.py +0 -0
  45. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/pump_status.py +0 -0
  46. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/relay.py +0 -0
  47. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/scenes.py +0 -0
  48. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/schedule.py +0 -0
  49. {python_mobius-0.10.1 → python_mobius-0.10.3}/src/mobius/timezone.py +0 -0
  50. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_attributes_batch.py +0 -0
  51. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_battery_settings.py +0 -0
  52. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_bluetooth_only.py +0 -0
  53. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_checksums.py +0 -0
  54. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_cli_group_targets.py +0 -0
  55. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_cli_mob_file.py +0 -0
  56. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_cli_schedule_file.py +0 -0
  57. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_coap.py +0 -0
  58. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_coap_indication_handling.py +0 -0
  59. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_configuration_cache.py +0 -0
  60. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_connection_cleanup.py +0 -0
  61. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_device_control.py +0 -0
  62. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_device_status.py +0 -0
  63. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_device_summary.py +0 -0
  64. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_device_time.py +0 -0
  65. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_discovery_company_ids.py +0 -0
  66. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_dump.py +0 -0
  67. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_extra_physical_values.py +0 -0
  68. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_fan_thresholds.py +0 -0
  69. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_firmware_versions.py +0 -0
  70. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_frame.py +0 -0
  71. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_frame_sniffer.py +0 -0
  72. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_full_poll_batch.py +0 -0
  73. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_group_bitmap.py +0 -0
  74. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_hyperdrive_device.py +0 -0
  75. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_insolation_detection.py +0 -0
  76. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_light_intensity_branching.py +0 -0
  77. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_light_poll_batch.py +0 -0
  78. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_light_schedule.py +0 -0
  79. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_manufacturer.py +0 -0
  80. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_manufacturer_lookup.py +0 -0
  81. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_mesh_address.py +0 -0
  82. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_mesh_discovery.py +0 -0
  83. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_mesh_discovery_direct_connect.py +0 -0
  84. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_metadata_batch.py +0 -0
  85. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_mob.py +0 -0
  86. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_modifiers.py +0 -0
  87. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_new_diagnostic_attributes.py +0 -0
  88. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_power.py +0 -0
  89. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_pump_param_ranges.py +0 -0
  90. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_pump_reverse.py +0 -0
  91. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_pump_schedule.py +0 -0
  92. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_pump_status.py +0 -0
  93. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_pump_supported_modes.py +0 -0
  94. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_pump_telemetry.py +0 -0
  95. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_scenes.py +0 -0
  96. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_schedule_file_format.py +0 -0
  97. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_schedule_intensity_scalar.py +0 -0
  98. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_serial_decoding.py +0 -0
  99. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_serial_identity.py +0 -0
  100. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_shared_helpers.py +0 -0
  101. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_supported_attributes.py +0 -0
  102. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_time_zone.py +0 -0
  103. {python_mobius-0.10.1 → python_mobius-0.10.3}/tests/test_write_size.py +0 -0
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.3
4
+
5
+ - Frames that fail to parse or fail their CRC are logged at debug level.
6
+
7
+ ## 0.10.2
8
+
9
+ - Mesh peer discovery can raise its error (`raise_errors=True`) instead
10
+ of returning an empty list on failure.
11
+
3
12
  ## 0.10.1
4
13
 
5
14
  - Fixed lights not being read through a gateway: the poll's reply was too
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-mobius
3
- Version: 0.10.1
3
+ Version: 0.10.3
4
4
  Summary: Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)
5
5
  Project-URL: Homepage, https://code.r3pek.org/r3pek/python-mobius
6
6
  Project-URL: Documentation, https://code.r3pek.org/r3pek/python-mobius/src/branch/main/documentation
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-mobius"
7
- version = "0.10.1"
7
+ version = "0.10.3"
8
8
  description = "Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)"
9
9
  readme = "README.md"
10
10
  license = { text = "GPL-2.0-only" }
@@ -87,7 +87,7 @@ from .mob import (
87
87
  MAX_PUMP_PRIMITIVE_SIZE,
88
88
  )
89
89
 
90
- __version__ = "0.10.1"
90
+ __version__ = "0.10.3"
91
91
 
92
92
  __all__ = [
93
93
  "__version__",
@@ -12,6 +12,7 @@ from __future__ import annotations
12
12
  import asyncio
13
13
  import datetime
14
14
  import struct
15
+ import logging
15
16
  import time
16
17
  from dataclasses import dataclass, field
17
18
  from typing import Optional, Callable, Any
@@ -79,6 +80,8 @@ CHAR_TX_DATA = "01ff0103-ba5e-f4ee-5ca1-eb1e5e4b1ce0" # write: app -> device,
79
80
  CHAR_TX_FINAL = "01ff0104-ba5e-f4ee-5ca1-eb1e5e4b1ce0" # write: app -> device, last fragment
80
81
 
81
82
  # count=0xFFFF requests every element of a list attribute.
83
+ _LOGGER = logging.getLogger(__name__)
84
+
82
85
  ALL_ELEMENTS = 0xFFFF
83
86
 
84
87
  # Write fragment size when the ATT MTU is unknown or the minimum (23).
@@ -87,6 +90,23 @@ DEFAULT_WRITE_SIZE = 20
87
90
  MTU_WRITE_OVERHEAD = 5
88
91
 
89
92
 
93
+ def _log_bad_frame(raw: bytes, frame, trace: list[tuple[str, int]]) -> None:
94
+ """Debug details of a received frame that doesn't parse or fails its
95
+ CRC: what was received against what its header declares, and the
96
+ notifications it was assembled from (D = RX_DATA, F = RX_FINAL)."""
97
+ if not _LOGGER.isEnabledFor(logging.DEBUG):
98
+ return
99
+ declared = struct.unpack_from("<H", raw, 7)[0] + 11 if len(raw) >= 9 else None
100
+ pieces = " ".join(f"{kind}{length}" for kind, length in trace)
101
+ _LOGGER.debug(
102
+ "Received a frame that %s: %d bytes, header declares %s; op_group=%s op_code=%s msg_id=%s; "
103
+ "%d notifications [%s]; first bytes %s; last bytes %s",
104
+ "doesn't parse" if frame is None else "fails its CRC", len(raw), declared,
105
+ f"{frame.op_group:#04x}" if frame else "?", frame.op_code if frame else "?",
106
+ frame.message_id if frame else "?", len(trace), pieces, raw[:24].hex(), raw[-24:].hex(),
107
+ )
108
+
109
+
90
110
  def write_size_for_mtu(att_mtu: Optional[int]) -> int:
91
111
  """Write fragment size for a negotiated ATT MTU: `att_mtu` minus 5, as
92
112
  the app does, or DEFAULT_WRITE_SIZE when it is unknown or not above the
@@ -917,6 +937,9 @@ class MobiusDevice:
917
937
  self.adapter = adapter
918
938
  self._client: Optional[BleakClient] = None
919
939
  self._rx_buffer = bytearray()
940
+ # ("D"/"F", length) of each notification of the frame being
941
+ # received, logged when it fails its CRC.
942
+ self._rx_trace: list[tuple[str, int]] = []
920
943
  # Pending direct requests, keyed by FSCI message ID.
921
944
  self._pending: dict[int, asyncio.Future] = {}
922
945
  # Pending relayed requests, keyed by CoAP token (see mobius.relay).
@@ -1014,12 +1037,18 @@ class MobiusDevice:
1014
1037
 
1015
1038
  def _on_rx_data(self, _handle, data: bytearray):
1016
1039
  self._rx_buffer.extend(data)
1040
+ self._rx_trace.append(("D", len(data)))
1017
1041
 
1018
1042
  def _on_rx_final(self, _handle, data: bytearray):
1019
1043
  self._rx_buffer.extend(data)
1044
+ self._rx_trace.append(("F", len(data)))
1020
1045
  raw = bytes(self._rx_buffer)
1046
+ trace = self._rx_trace
1021
1047
  self._rx_buffer.clear()
1048
+ self._rx_trace = []
1022
1049
  frame = parse_frame(raw)
1050
+ if frame is None or not frame.crc_ok:
1051
+ _log_bad_frame(raw, frame, trace)
1023
1052
  if frame is None:
1024
1053
  return
1025
1054
  if self.on_any_frame is not None:
@@ -1842,7 +1871,7 @@ class MobiusDevice:
1842
1871
  return None
1843
1872
  return raw
1844
1873
 
1845
- async def discover_mesh_peers(self) -> list[MeshPeer]:
1874
+ async def discover_mesh_peers(self, raise_errors: bool = False) -> list[MeshPeer]:
1846
1875
  """
1847
1876
  Mesh peers from the ShortAddressArray/SerialNumberArray/
1848
1877
  DeviceModelArray (3700/3701/3702) parallel arrays, with addresses
@@ -1852,10 +1881,12 @@ class MobiusDevice:
1852
1881
  UnsupportedAttribute and this returns []. Whether the connected
1853
1882
  device lists itself is not known. Empty slots (serial
1854
1883
  "00000000000000") and short entries are skipped. Returns [] on any
1855
- failure.
1884
+ failure, or raises it with raise_errors=True.
1856
1885
  """
1857
1886
  own_address = await self.get_own_mesh_address()
1858
1887
  if own_address is None:
1888
+ if raise_errors:
1889
+ raise IOError("own mesh address (MeshLocalAddresses) not readable")
1859
1890
  return []
1860
1891
  prefix = mesh_local_prefix_from_own_address(own_address)
1861
1892
 
@@ -1864,6 +1895,8 @@ class MobiusDevice:
1864
1895
  models_raw = await self.get_attribute(C2Attribute.DeviceModelArray, index=0, count=ALL_ELEMENTS)
1865
1896
  shorts_raw = await self.get_attribute(C2Attribute.ShortAddressArray, index=0, count=ALL_ELEMENTS)
1866
1897
  except Exception:
1898
+ if raise_errors:
1899
+ raise
1867
1900
  return []
1868
1901
 
1869
1902
  peers: list[MeshPeer] = []
@@ -1882,7 +1915,7 @@ class MobiusDevice:
1882
1915
  ))
1883
1916
  return peers
1884
1917
 
1885
- async def discover_networked_thread_devices(self) -> list[MeshPeer]:
1918
+ async def discover_networked_thread_devices(self, raise_errors: bool = False) -> list[MeshPeer]:
1886
1919
  """
1887
1920
  Every device on this device's Thread mesh, including itself, from
1888
1921
  MLPrefix (2128) and NetworkedThreadDevices (1001). Supported more
@@ -1896,7 +1929,8 @@ class MobiusDevice:
1896
1929
  12-13 model id, i16 LE
1897
1930
  14-27 serial, ASCII
1898
1931
  Empty slots are all zeros and are skipped, as are entries of any
1899
- other length. Returns [] on any failure.
1932
+ other length. Returns [] on any failure, or raises it with
1933
+ raise_errors=True.
1900
1934
  """
1901
1935
  try:
1902
1936
  mlprefix_raw = await self.get_attribute(C2Attribute.MLPrefix)
@@ -1904,8 +1938,15 @@ class MobiusDevice:
1904
1938
  C2Attribute.NetworkedThreadDevices, index=0, count=ALL_ELEMENTS,
1905
1939
  )
1906
1940
  except Exception:
1941
+ if raise_errors:
1942
+ raise
1907
1943
  return []
1908
1944
  if not mlprefix_raw or len(mlprefix_raw[0]) != 24:
1945
+ if raise_errors:
1946
+ raise IOError(
1947
+ f"unexpected MLPrefix: {mlprefix_raw[0].hex() if mlprefix_raw else 'empty'} "
1948
+ f"(expected 24 bytes)"
1949
+ )
1909
1950
  return []
1910
1951
  prefix = mesh_local_prefix_from_mlprefix(mlprefix_raw[0])
1911
1952
 
@@ -1928,18 +1969,35 @@ class MobiusDevice:
1928
1969
  ))
1929
1970
  return peers
1930
1971
 
1931
- async def discover_mesh_peers_auto(self) -> list[MeshPeer]:
1972
+ async def discover_mesh_peers_auto(self, raise_errors: bool = False) -> list[MeshPeer]:
1932
1973
  """
1933
1974
  discover_networked_thread_devices(), falling back to
1934
1975
  discover_mesh_peers() when it finds nothing. The recommended entry
1935
1976
  point. mobius.discovery.discover_mesh_peers_via_direct_connect() is
1936
1977
  the remaining fallback, but it needs scan results and has to be
1937
1978
  called explicitly.
1979
+
1980
+ raise_errors=True: a failure raises instead of looking like an
1981
+ empty mesh. The fallback is then only tried when
1982
+ NetworkedThreadDevices can't be read (an MXM hub), and the error
1983
+ raised names both attempts.
1938
1984
  """
1939
- peers = await self.discover_networked_thread_devices()
1940
- if peers:
1941
- return peers
1942
- return await self.discover_mesh_peers()
1985
+ if not raise_errors:
1986
+ peers = await self.discover_networked_thread_devices()
1987
+ if peers:
1988
+ return peers
1989
+ return await self.discover_mesh_peers()
1990
+
1991
+ try:
1992
+ return await self.discover_networked_thread_devices(raise_errors=True)
1993
+ except Exception as networked_error:
1994
+ try:
1995
+ return await self.discover_mesh_peers(raise_errors=True)
1996
+ except Exception as arrays_error:
1997
+ raise IOError(
1998
+ f"NetworkedThreadDevices: {networked_error or type(networked_error).__name__}; "
1999
+ f"peer arrays: {arrays_error or type(arrays_error).__name__}"
2000
+ ) from networked_error
1943
2001
 
1944
2002
  async def get_firmware_versions(self, model: Optional[Model] = None) -> dict:
1945
2003
  """{label: version} for every firmware component (FirmwareVersion,
@@ -328,3 +328,68 @@ async def test_discover_networked_thread_devices_real_hardware_capture():
328
328
  assert p.model == Model.RadionXR15wG6Pro
329
329
  assert p.age == 8490
330
330
  assert p.short_address == 0x0400
331
+
332
+
333
+ @pytest.mark.asyncio
334
+ async def test_raise_errors_reports_a_failed_read_instead_of_an_empty_mesh():
335
+ device = MobiusDevice(_fake_ble_device())
336
+
337
+ async def fake_get_attribute(attr_id, index=0, count=1):
338
+ raise IOError("timed out")
339
+
340
+ device.get_attribute = fake_get_attribute
341
+
342
+ assert await device.discover_networked_thread_devices() == []
343
+ with pytest.raises(IOError, match="timed out"):
344
+ await device.discover_networked_thread_devices(raise_errors=True)
345
+
346
+
347
+ @pytest.mark.asyncio
348
+ async def test_raise_errors_reports_an_unexpected_mlprefix():
349
+ device = MobiusDevice(_fake_ble_device())
350
+
351
+ async def fake_get_attribute(attr_id, index=0, count=1):
352
+ if attr_id == C2Attribute.MLPrefix:
353
+ return [PREFIX] # 8 bytes instead of 24
354
+ return []
355
+
356
+ device.get_attribute = fake_get_attribute
357
+
358
+ with pytest.raises(IOError, match="unexpected MLPrefix"):
359
+ await device.discover_networked_thread_devices(raise_errors=True)
360
+
361
+
362
+ @pytest.mark.asyncio
363
+ async def test_auto_with_raise_errors_names_both_attempts_when_both_fail():
364
+ device = MobiusDevice(_fake_ble_device())
365
+
366
+ async def fake_get_attribute(attr_id, index=0, count=1):
367
+ raise IOError(f"no answer for {int(attr_id)}")
368
+
369
+ async def no_own_address():
370
+ return None
371
+
372
+ device.get_attribute = fake_get_attribute
373
+ device.get_own_mesh_address = no_own_address
374
+
375
+ assert await device.discover_mesh_peers_auto() == []
376
+ with pytest.raises(IOError, match=r"NetworkedThreadDevices: no answer for 2128; peer arrays: own mesh address"):
377
+ await device.discover_mesh_peers_auto(raise_errors=True)
378
+
379
+
380
+ @pytest.mark.asyncio
381
+ async def test_auto_with_raise_errors_returns_networked_devices():
382
+ device = MobiusDevice(_fake_ble_device())
383
+ suffix = bytes.fromhex("000000fffe00") + struct.pack("<H", 0x1234)
384
+
385
+ async def fake_get_attribute(attr_id, index=0, count=1):
386
+ if attr_id == C2Attribute.MLPrefix:
387
+ return [MLPREFIX_RAW]
388
+ if attr_id == C2Attribute.NetworkedThreadDevices:
389
+ return [_entry(suffix, 42, Model.VorTechMP40wG3QD, "00000000000001")]
390
+ raise AssertionError("the peer arrays must not be read")
391
+
392
+ device.get_attribute = fake_get_attribute
393
+
394
+ peers = await device.discover_mesh_peers_auto(raise_errors=True)
395
+ assert [p.serial for p in peers] == ["00000000000001"]
@@ -819,3 +819,17 @@ class TestRelayedMultiBlockResponse:
819
819
  versions = await relayed.get_firmware_versions(model=Model.RadionXR15wG6Pro)
820
820
  assert versions["Product OS"] == "1.0"
821
821
  assert versions["Radio Firmware"] == "1.5.103"
822
+
823
+
824
+ def test_a_frame_failing_its_crc_is_logged_with_its_notifications(caplog):
825
+ caplog.set_level(logging.DEBUG, logger="mobius.device")
826
+ device = MobiusDevice(BLEDevice("AA:BB:CC:DD:EE:FF", "MOBIUS", {}))
827
+ frame = bytearray(build_frame(OPGROUP_C2CI_REQUEST, 1, b"\x00" * 30, 7))
828
+ frame[-1] ^= 0xFF # corrupt the CRC
829
+
830
+ device._on_rx_data(None, frame[:20])
831
+ device._on_rx_final(None, frame[20:])
832
+
833
+ assert "fails its CRC" in caplog.text
834
+ assert "2 notifications [D20 F" in caplog.text
835
+ assert device._rx_trace == []
File without changes
File without changes