python-mobius 0.10.0__tar.gz → 0.10.2__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.0 → python_mobius-0.10.2}/CHANGELOG.md +11 -0
  2. {python_mobius-0.10.0 → python_mobius-0.10.2}/PKG-INFO +1 -1
  3. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/09-thread-coap-relay.md +15 -0
  4. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/17-configuration-checksums.md +4 -0
  5. {python_mobius-0.10.0 → python_mobius-0.10.2}/pyproject.toml +1 -1
  6. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/__init__.py +1 -1
  7. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/device.py +90 -63
  8. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_configuration_cache.py +42 -12
  9. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_networked_thread_devices.py +65 -0
  10. {python_mobius-0.10.0 → python_mobius-0.10.2}/.forgejo/scripts/extract_changelog_section.py +0 -0
  11. {python_mobius-0.10.0 → python_mobius-0.10.2}/.forgejo/workflows/release.yml +0 -0
  12. {python_mobius-0.10.0 → python_mobius-0.10.2}/.gitignore +0 -0
  13. {python_mobius-0.10.0 → python_mobius-0.10.2}/LICENSE +0 -0
  14. {python_mobius-0.10.0 → python_mobius-0.10.2}/README.md +0 -0
  15. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/00-overview.md +0 -0
  16. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/01-ble-transport.md +0 -0
  17. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/02-framing-and-crc.md +0 -0
  18. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/03-attributes-and-opcodes.md +0 -0
  19. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/04-device-identity.md +0 -0
  20. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/05-scenes.md +0 -0
  21. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/06-light-schedule.md +0 -0
  22. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/07-pump-schedule.md +0 -0
  23. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/08-manufacturer-data.md +0 -0
  24. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/10-known-gaps-and-open-questions.md +0 -0
  25. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/11-device-status-attributes.md +0 -0
  26. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/12-device-identity-and-address-stability.md +0 -0
  27. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/13-attribute-dump.md +0 -0
  28. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/14-attribute-reference.md +0 -0
  29. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/15-mob-template-format.md +0 -0
  30. {python_mobius-0.10.0 → python_mobius-0.10.2}/documentation/16-python-library.md +0 -0
  31. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/checksums.py +0 -0
  32. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/cli.py +0 -0
  33. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/coap.py +0 -0
  34. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/constants.py +0 -0
  35. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/crc.py +0 -0
  36. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/device_status.py +0 -0
  37. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/discovery.py +0 -0
  38. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/dump.py +0 -0
  39. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/frame.py +0 -0
  40. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/manufacturer.py +0 -0
  41. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/mesh_address.py +0 -0
  42. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/mob.py +0 -0
  43. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/modifiers.py +0 -0
  44. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/power.py +0 -0
  45. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/pump_status.py +0 -0
  46. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/relay.py +0 -0
  47. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/scenes.py +0 -0
  48. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/schedule.py +0 -0
  49. {python_mobius-0.10.0 → python_mobius-0.10.2}/src/mobius/timezone.py +0 -0
  50. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_attributes_batch.py +0 -0
  51. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_battery_settings.py +0 -0
  52. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_bluetooth_only.py +0 -0
  53. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_checksums.py +0 -0
  54. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_cli_group_targets.py +0 -0
  55. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_cli_mob_file.py +0 -0
  56. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_cli_schedule_file.py +0 -0
  57. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_coap.py +0 -0
  58. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_coap_indication_handling.py +0 -0
  59. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_connection_cleanup.py +0 -0
  60. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_device_control.py +0 -0
  61. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_device_status.py +0 -0
  62. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_device_summary.py +0 -0
  63. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_device_time.py +0 -0
  64. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_discovery_company_ids.py +0 -0
  65. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_dump.py +0 -0
  66. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_extra_physical_values.py +0 -0
  67. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_fan_thresholds.py +0 -0
  68. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_firmware_versions.py +0 -0
  69. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_frame.py +0 -0
  70. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_frame_sniffer.py +0 -0
  71. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_full_poll_batch.py +0 -0
  72. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_group_bitmap.py +0 -0
  73. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_hyperdrive_device.py +0 -0
  74. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_insolation_detection.py +0 -0
  75. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_light_intensity_branching.py +0 -0
  76. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_light_poll_batch.py +0 -0
  77. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_light_schedule.py +0 -0
  78. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_manufacturer.py +0 -0
  79. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_manufacturer_lookup.py +0 -0
  80. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_mesh_address.py +0 -0
  81. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_mesh_discovery.py +0 -0
  82. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_mesh_discovery_direct_connect.py +0 -0
  83. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_metadata_batch.py +0 -0
  84. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_mob.py +0 -0
  85. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_modifiers.py +0 -0
  86. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_new_diagnostic_attributes.py +0 -0
  87. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_power.py +0 -0
  88. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_pump_param_ranges.py +0 -0
  89. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_pump_reverse.py +0 -0
  90. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_pump_schedule.py +0 -0
  91. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_pump_status.py +0 -0
  92. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_pump_supported_modes.py +0 -0
  93. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_pump_telemetry.py +0 -0
  94. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_relay.py +0 -0
  95. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_scenes.py +0 -0
  96. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_schedule_file_format.py +0 -0
  97. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_schedule_intensity_scalar.py +0 -0
  98. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_serial_decoding.py +0 -0
  99. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_serial_identity.py +0 -0
  100. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_shared_helpers.py +0 -0
  101. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_supported_attributes.py +0 -0
  102. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_time_zone.py +0 -0
  103. {python_mobius-0.10.0 → python_mobius-0.10.2}/tests/test_write_size.py +0 -0
@@ -1,5 +1,16 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.2
4
+
5
+ - Mesh peer discovery can raise its error (`raise_errors=True`) instead
6
+ of returning an empty list on failure.
7
+
8
+ ## 0.10.1
9
+
10
+ - Fixed lights not being read through a gateway: the poll's reply was too
11
+ large to be relayed. The schedule and scenes are now read in requests
12
+ of their own, as the app does.
13
+
3
14
  ## 0.10.0
4
15
 
5
16
  - Added `set_time_zone()`; the batched polls now include the device's time
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-mobius
3
- Version: 0.10.0
3
+ Version: 0.10.2
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
@@ -156,6 +156,21 @@ A gateway can lose its route to a single target while everything else
156
156
  keeps working. The protocol has no command to rebuild the mesh; switching
157
157
  to another device as gateway is the practical recovery.
158
158
 
159
+ ## Reply size
160
+
161
+ A relayed reply that is too large never arrives: the request times out
162
+ as if the target were unreachable, while smaller requests to the same
163
+ target keep working. Seen with a Radion XR15 G6 relayed through a VorTech
164
+ MP40: a poll batch whose reply was about 2 KB (schedule, scenes and
165
+ metadata together) always timed out, the same data read in three requests
166
+ arrived. The limit is somewhere above 1.1 KB (a 25-point light schedule
167
+ alone, about 1.05 KB of data, arrives).
168
+
169
+ The app reads the schedule (and the Insolation schedule and driver
170
+ information) in a request of its own, and everything else together; the
171
+ Insolation table even takes 8 requests. `get_full_poll_batch()` does the
172
+ same with the schedule and `ConfiguredScenes`.
173
+
159
174
  ## Writes and the mesh
160
175
 
161
176
  A relayed Set reaches only its target. A Set sent with the reserved
@@ -62,6 +62,10 @@ To read scenes and a schedule only when they changed:
62
62
  kept one, the kept data is current.
63
63
  3. If it differs, or can't be read, read the data and its checksum again.
64
64
 
65
+ Read each data attribute in a request of its own: a light's schedule or
66
+ scenes are close to the largest reply a relay carries
67
+ ([09](./09-thread-coap-relay.md#reply-size)).
68
+
65
69
  The key is the device's own value, so this works without computing
66
70
  anything and also catches changes made by other clients (the app, or
67
71
  this client's own writes). Include `SupportedAttributesCRC` in the poll: a
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-mobius"
7
- version = "0.10.0"
7
+ version = "0.10.2"
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.0"
90
+ __version__ = "0.10.2"
91
91
 
92
92
  __all__ = [
93
93
  "__version__",
@@ -169,26 +169,18 @@ def _schedule_checksum_attr(which: int) -> C2Attribute:
169
169
  return C2Attribute.Schedule1Checksum if which == 1 else C2Attribute.Schedule2Checksum
170
170
 
171
171
 
172
- def _configuration_requests(
172
+ def _configuration_checksum_requests(
173
173
  configuration: tuple, cache: Optional[ConfigurationCache], supported_attribute_ids: set,
174
- ) -> tuple[list, set]:
175
- """
176
- Poll requests for each (data attribute, checksum attribute) of
177
- `configuration`: the checksum alone when `cache` holds the data, else
178
- the data (plus the checksum, to store with it). Returns (requests,
179
- data attributes requested).
180
- """
181
- requests, data_read = [], set()
182
- for data_attr, checksum_attr in configuration:
183
- checksum_usable = cache is not None and checksum_attr in supported_attribute_ids
184
- if checksum_usable and data_attr in cache.entries:
185
- requests.append((checksum_attr, 0, 1))
186
- continue
187
- requests.append((data_attr, 0, ALL_ELEMENTS))
188
- data_read.add(data_attr)
189
- if checksum_usable:
190
- requests.append((checksum_attr, 0, 1))
191
- return requests, data_read
174
+ ) -> list:
175
+ """The checksum request of each (data attribute, checksum attribute) of
176
+ `configuration` that the cache can use. The data itself is never part
177
+ of the poll batch (see _read_configuration())."""
178
+ if cache is None:
179
+ return []
180
+ return [
181
+ (checksum_attr, 0, 1) for _data_attr, checksum_attr in configuration
182
+ if checksum_attr in supported_attribute_ids
183
+ ]
192
184
 
193
185
 
194
186
  def _apply_static_cache(cache: ConfigurationCache, by_attr_id: dict, static_read: bool) -> None:
@@ -1850,7 +1842,7 @@ class MobiusDevice:
1850
1842
  return None
1851
1843
  return raw
1852
1844
 
1853
- async def discover_mesh_peers(self) -> list[MeshPeer]:
1845
+ async def discover_mesh_peers(self, raise_errors: bool = False) -> list[MeshPeer]:
1854
1846
  """
1855
1847
  Mesh peers from the ShortAddressArray/SerialNumberArray/
1856
1848
  DeviceModelArray (3700/3701/3702) parallel arrays, with addresses
@@ -1860,10 +1852,12 @@ class MobiusDevice:
1860
1852
  UnsupportedAttribute and this returns []. Whether the connected
1861
1853
  device lists itself is not known. Empty slots (serial
1862
1854
  "00000000000000") and short entries are skipped. Returns [] on any
1863
- failure.
1855
+ failure, or raises it with raise_errors=True.
1864
1856
  """
1865
1857
  own_address = await self.get_own_mesh_address()
1866
1858
  if own_address is None:
1859
+ if raise_errors:
1860
+ raise IOError("own mesh address (MeshLocalAddresses) not readable")
1867
1861
  return []
1868
1862
  prefix = mesh_local_prefix_from_own_address(own_address)
1869
1863
 
@@ -1872,6 +1866,8 @@ class MobiusDevice:
1872
1866
  models_raw = await self.get_attribute(C2Attribute.DeviceModelArray, index=0, count=ALL_ELEMENTS)
1873
1867
  shorts_raw = await self.get_attribute(C2Attribute.ShortAddressArray, index=0, count=ALL_ELEMENTS)
1874
1868
  except Exception:
1869
+ if raise_errors:
1870
+ raise
1875
1871
  return []
1876
1872
 
1877
1873
  peers: list[MeshPeer] = []
@@ -1890,7 +1886,7 @@ class MobiusDevice:
1890
1886
  ))
1891
1887
  return peers
1892
1888
 
1893
- async def discover_networked_thread_devices(self) -> list[MeshPeer]:
1889
+ async def discover_networked_thread_devices(self, raise_errors: bool = False) -> list[MeshPeer]:
1894
1890
  """
1895
1891
  Every device on this device's Thread mesh, including itself, from
1896
1892
  MLPrefix (2128) and NetworkedThreadDevices (1001). Supported more
@@ -1904,7 +1900,8 @@ class MobiusDevice:
1904
1900
  12-13 model id, i16 LE
1905
1901
  14-27 serial, ASCII
1906
1902
  Empty slots are all zeros and are skipped, as are entries of any
1907
- other length. Returns [] on any failure.
1903
+ other length. Returns [] on any failure, or raises it with
1904
+ raise_errors=True.
1908
1905
  """
1909
1906
  try:
1910
1907
  mlprefix_raw = await self.get_attribute(C2Attribute.MLPrefix)
@@ -1912,8 +1909,15 @@ class MobiusDevice:
1912
1909
  C2Attribute.NetworkedThreadDevices, index=0, count=ALL_ELEMENTS,
1913
1910
  )
1914
1911
  except Exception:
1912
+ if raise_errors:
1913
+ raise
1915
1914
  return []
1916
1915
  if not mlprefix_raw or len(mlprefix_raw[0]) != 24:
1916
+ if raise_errors:
1917
+ raise IOError(
1918
+ f"unexpected MLPrefix: {mlprefix_raw[0].hex() if mlprefix_raw else 'empty'} "
1919
+ f"(expected 24 bytes)"
1920
+ )
1917
1921
  return []
1918
1922
  prefix = mesh_local_prefix_from_mlprefix(mlprefix_raw[0])
1919
1923
 
@@ -1936,18 +1940,35 @@ class MobiusDevice:
1936
1940
  ))
1937
1941
  return peers
1938
1942
 
1939
- async def discover_mesh_peers_auto(self) -> list[MeshPeer]:
1943
+ async def discover_mesh_peers_auto(self, raise_errors: bool = False) -> list[MeshPeer]:
1940
1944
  """
1941
1945
  discover_networked_thread_devices(), falling back to
1942
1946
  discover_mesh_peers() when it finds nothing. The recommended entry
1943
1947
  point. mobius.discovery.discover_mesh_peers_via_direct_connect() is
1944
1948
  the remaining fallback, but it needs scan results and has to be
1945
1949
  called explicitly.
1950
+
1951
+ raise_errors=True: a failure raises instead of looking like an
1952
+ empty mesh. The fallback is then only tried when
1953
+ NetworkedThreadDevices can't be read (an MXM hub), and the error
1954
+ raised names both attempts.
1946
1955
  """
1947
- peers = await self.discover_networked_thread_devices()
1948
- if peers:
1949
- return peers
1950
- return await self.discover_mesh_peers()
1956
+ if not raise_errors:
1957
+ peers = await self.discover_networked_thread_devices()
1958
+ if peers:
1959
+ return peers
1960
+ return await self.discover_mesh_peers()
1961
+
1962
+ try:
1963
+ return await self.discover_networked_thread_devices(raise_errors=True)
1964
+ except Exception as networked_error:
1965
+ try:
1966
+ return await self.discover_mesh_peers(raise_errors=True)
1967
+ except Exception as arrays_error:
1968
+ raise IOError(
1969
+ f"NetworkedThreadDevices: {networked_error or type(networked_error).__name__}; "
1970
+ f"peer arrays: {arrays_error or type(arrays_error).__name__}"
1971
+ ) from networked_error
1951
1972
 
1952
1973
  async def get_firmware_versions(self, model: Optional[Model] = None) -> dict:
1953
1974
  """{label: version} for every firmware component (FirmwareVersion,
@@ -2506,10 +2527,12 @@ class MobiusDevice:
2506
2527
  get_device_info(), get_metadata_batch() and
2507
2528
  get_light_poll_batch() or get_pump_schedule()/get_pump_telemetry().
2508
2529
 
2509
- `cache` (one per device, kept by the caller): ConfiguredScenes and
2510
- the schedule are then read only when their checksum changed since
2511
- the previous poll (see ConfigurationCache); a changed checksum
2512
- costs a second, smaller request. With its static_refresh_interval
2530
+ The schedule and ConfiguredScenes are read in requests of their
2531
+ own after the batch, as the app does: combined with the rest, a
2532
+ light's reply is too large to be relayed. `cache` (one per device,
2533
+ kept by the caller): they are then read only when their checksum,
2534
+ part of the batch, changed since the previous poll (see
2535
+ ConfigurationCache). With its static_refresh_interval
2513
2536
  set, the STATIC_POLL_ATTRIBUTES are also read only that often.
2514
2537
  Used by the batched path only.
2515
2538
  """
@@ -2537,7 +2560,7 @@ class MobiusDevice:
2537
2560
  (schedule_attr, _schedule_checksum_attr(which)),
2538
2561
  (C2Attribute.ConfiguredScenes, C2Attribute.ConfiguredScenesChecksum),
2539
2562
  )
2540
- configuration_requests, data_read = _configuration_requests(configuration, cache, supported_attribute_ids)
2563
+ configuration_requests = _configuration_checksum_requests(configuration, cache, supported_attribute_ids)
2541
2564
  if cache is not None:
2542
2565
  configuration_requests.append((C2Attribute.SupportedAttributesCRC, 0, 1))
2543
2566
  requests = _DEVICE_INFO_REQUESTS + _METADATA_REQUESTS + configuration_requests + [
@@ -2558,9 +2581,7 @@ class MobiusDevice:
2558
2581
  )
2559
2582
  if attribute_table_changed:
2560
2583
  cache.invalidate_static()
2561
- data_read |= await self._apply_configuration_cache(
2562
- by_attr_id, cache, configuration, data_read, supported_attribute_ids,
2563
- )
2584
+ data_read = await self._read_configuration(by_attr_id, cache, configuration, supported_attribute_ids)
2564
2585
 
2565
2586
  def first(attr_id, index=0):
2566
2587
  return _first_value(by_attr_id, attr_id, index)
@@ -2621,38 +2642,44 @@ class MobiusDevice:
2621
2642
  static_read=static_read,
2622
2643
  )
2623
2644
 
2624
- async def _apply_configuration_cache(
2625
- self, by_attr_id: dict, cache: ConfigurationCache,
2626
- configuration: tuple, data_read: set, supported_attribute_ids: set,
2645
+ async def _read_configuration(
2646
+ self, by_attr_id: dict, cache: Optional[ConfigurationCache],
2647
+ configuration: tuple, supported_attribute_ids: set,
2627
2648
  ) -> set:
2628
2649
  """
2629
- After the poll batch: stores data read with its checksum, fills in
2630
- cached data whose checksum is unchanged, and reads the rest in a
2631
- second batch. Returns the attributes read by that second batch.
2650
+ Fills each data attribute of `configuration` (the schedule, the
2651
+ scenes) into `by_attr_id`: from `cache` when the checksum in the
2652
+ poll batch matches the cached one, else with a request of its own
2653
+ (plus its checksum, stored in the cache with it). Returns the
2654
+ attributes read.
2655
+
2656
+ Each gets its own request, as in the app: a light's schedule or
2657
+ scenes alone are close to the largest reply a relay carries, and
2658
+ together with the rest of the poll a relayed reply is too large
2659
+ and never arrives.
2632
2660
  """
2633
- stale = []
2661
+ read = set()
2634
2662
  for data_attr, checksum_attr in configuration:
2635
- if data_attr not in supported_attribute_ids or checksum_attr not in supported_attribute_ids:
2663
+ if data_attr not in supported_attribute_ids:
2636
2664
  continue
2637
- reported = _unpack(_first_value(by_attr_id, checksum_attr), "<H")
2638
- if data_attr in data_read:
2639
- _store_configuration(cache, data_attr, reported, by_attr_id)
2640
- continue
2641
- cached = cache.entries.get(data_attr)
2642
- if cached is not None and reported is not None and reported == cached[0]:
2643
- by_attr_id[data_attr] = cached[1]
2644
- else:
2645
- stale.append((data_attr, checksum_attr))
2646
- if not stale:
2647
- return set()
2648
- second = await self._read_supported_batch(
2649
- [r for data_attr, checksum_attr in stale for r in ((data_attr, 0, ALL_ELEMENTS), (checksum_attr, 0, 1))],
2650
- supported_attribute_ids,
2651
- )
2652
- for data_attr, checksum_attr in stale:
2653
- by_attr_id[data_attr] = second.get(data_attr, [])
2654
- _store_configuration(cache, data_attr, _unpack(_first_value(second, checksum_attr), "<H"), second)
2655
- return {data_attr for data_attr, _checksum_attr in stale}
2665
+ checksum_usable = cache is not None and checksum_attr in supported_attribute_ids
2666
+ if checksum_usable:
2667
+ reported = _unpack(_first_value(by_attr_id, checksum_attr), "<H")
2668
+ cached = cache.entries.get(data_attr)
2669
+ if cached is not None and reported is not None and reported == cached[0]:
2670
+ by_attr_id[data_attr] = cached[1]
2671
+ continue
2672
+ requests = [(data_attr, 0, ALL_ELEMENTS)]
2673
+ if checksum_usable:
2674
+ requests.append((checksum_attr, 0, 1))
2675
+ response = await self._read_supported_batch(requests, supported_attribute_ids)
2676
+ by_attr_id[data_attr] = response.get(int(data_attr), [])
2677
+ if checksum_usable:
2678
+ _store_configuration(
2679
+ cache, data_attr, _unpack(_first_value(response, checksum_attr), "<H"), response,
2680
+ )
2681
+ read.add(data_attr)
2682
+ return read
2656
2683
 
2657
2684
  async def _get_full_poll_via_individual_reads(
2658
2685
  self, primitive: PrimitiveType, model: Optional[Model],
@@ -60,6 +60,14 @@ def _requested(pump, attr, batch=-1):
60
60
  return int(attr) in pump.batches[batch]
61
61
 
62
62
 
63
+ def _requested_in_any(pump, attr, batches):
64
+ return any(int(attr) in batch for batch in batches)
65
+
66
+
67
+ SCHEDULE_ALONE = [int(C2Attribute.Schedule1), int(C2Attribute.Schedule1Checksum)]
68
+ SCENES_ALONE = [int(C2Attribute.ConfiguredScenes), int(C2Attribute.ConfiguredScenesChecksum)]
69
+
70
+
63
71
  @pytest.mark.asyncio
64
72
  async def test_first_poll_reads_the_data_with_its_checksums():
65
73
  pump, cache = FakePump(), ConfigurationCache()
@@ -67,7 +75,10 @@ async def test_first_poll_reads_the_data_with_its_checksums():
67
75
  result = await pump.poll(cache)
68
76
 
69
77
  assert result.configuration_read == ("Schedule1", "ConfiguredScenes")
70
- assert len(pump.batches) == 1
78
+ # The poll batch, then the schedule and the scenes each on their own.
79
+ assert pump.batches[1:] == [SCHEDULE_ALONE, SCENES_ALONE]
80
+ assert not _requested(pump, C2Attribute.Schedule1, batch=0)
81
+ assert not _requested(pump, C2Attribute.ConfiguredScenes, batch=0)
71
82
  assert cache.entries[int(C2Attribute.Schedule1)][0] == 0x1111
72
83
  assert cache.entries[int(C2Attribute.ConfiguredScenes)][0] == 0x2222
73
84
  assert result.pump_schedule_points[0].pump.params[PumpParam.MaxSpeed] == 500
@@ -80,7 +91,7 @@ async def test_unchanged_checksums_skip_the_data():
80
91
 
81
92
  result = await pump.poll(cache)
82
93
 
83
- assert len(pump.batches) == 2
94
+ assert len(pump.batches) == 4 # one request for this poll
84
95
  assert not _requested(pump, C2Attribute.Schedule1)
85
96
  assert not _requested(pump, C2Attribute.ConfiguredScenes)
86
97
  assert _requested(pump, C2Attribute.Schedule1Checksum)
@@ -90,7 +101,7 @@ async def test_unchanged_checksums_skip_the_data():
90
101
 
91
102
 
92
103
  @pytest.mark.asyncio
93
- async def test_changed_checksum_reads_that_data_in_a_second_request():
104
+ async def test_changed_checksum_reads_that_data_in_a_request_of_its_own():
94
105
  pump, cache = FakePump(), ConfigurationCache()
95
106
  await pump.poll(cache)
96
107
  pump.set(C2Attribute.Schedule1, [_point(700)])
@@ -98,8 +109,9 @@ async def test_changed_checksum_reads_that_data_in_a_second_request():
98
109
 
99
110
  result = await pump.poll(cache)
100
111
 
101
- assert len(pump.batches) == 3
102
- assert pump.batches[-1] == [int(C2Attribute.Schedule1), int(C2Attribute.Schedule1Checksum)]
112
+ # This poll: the batch, then the schedule alone; the scenes are cached.
113
+ assert len(pump.batches) == 5
114
+ assert pump.batches[-1] == SCHEDULE_ALONE
103
115
  assert result.configuration_read == ("Schedule1",)
104
116
  assert result.pump_schedule_points[0].pump.params[PumpParam.MaxSpeed] == 700
105
117
  assert cache.entries[int(C2Attribute.Schedule1)][0] == 0x3333
@@ -124,8 +136,7 @@ async def test_without_checksum_support_the_data_is_read_every_poll():
124
136
 
125
137
  result = await pump.poll(cache)
126
138
 
127
- assert _requested(pump, C2Attribute.Schedule1)
128
- assert _requested(pump, C2Attribute.ConfiguredScenes)
139
+ assert pump.batches[-2:] == [[int(C2Attribute.Schedule1)], [int(C2Attribute.ConfiguredScenes)]]
129
140
  assert result.configuration_read == ("Schedule1", "ConfiguredScenes")
130
141
  assert cache.entries == {}
131
142
 
@@ -141,13 +152,13 @@ async def test_attribute_table_change_is_reported_once():
141
152
 
142
153
 
143
154
  @pytest.mark.asyncio
144
- async def test_without_a_cache_nothing_changes():
155
+ async def test_without_a_cache_the_data_is_read_in_its_own_requests():
145
156
  pump = FakePump()
146
157
  await pump.poll(None)
147
158
 
148
- assert _requested(pump, C2Attribute.Schedule1)
149
- assert not _requested(pump, C2Attribute.Schedule1Checksum)
150
- assert not _requested(pump, C2Attribute.SupportedAttributesCRC)
159
+ assert pump.batches[1:] == [[int(C2Attribute.Schedule1)], [int(C2Attribute.ConfiguredScenes)]]
160
+ assert not _requested_in_any(pump, C2Attribute.Schedule1Checksum, pump.batches)
161
+ assert not _requested_in_any(pump, C2Attribute.SupportedAttributesCRC, pump.batches)
151
162
 
152
163
 
153
164
  @pytest.mark.asyncio
@@ -256,4 +267,23 @@ async def test_pump_operation_state_is_part_of_the_batch():
256
267
  result = await pump.poll(cache)
257
268
 
258
269
  assert result.operation_state == OperationState(2)
259
- assert len(pump.batches) == 1
270
+ assert _requested(pump, C2Attribute.OperationState, batch=0)
271
+
272
+
273
+ @pytest.mark.asyncio
274
+ async def test_poll_batch_stays_small_with_a_large_schedule_and_scenes():
275
+ """A relay only carries replies up to a limit: the schedule and scenes
276
+ must never share a request with each other or with the rest."""
277
+ light, cache = FakePump(), ConfigurationCache()
278
+ light.set(C2Attribute.Schedule1, [_point(500)] * 25)
279
+ light.set(C2Attribute.ConfiguredScenes, [SCENE] * 10)
280
+
281
+ await light.device.get_full_poll_batch(
282
+ primitive=PrimitiveType.VisualV1, supported_attribute_ids=light.supported, cache=cache, minute_of_day=0,
283
+ )
284
+
285
+ big = {int(C2Attribute.Schedule1), int(C2Attribute.ConfiguredScenes)}
286
+ for batch in light.batches:
287
+ assert len(big & set(batch)) <= 1
288
+ if big & set(batch):
289
+ assert len(batch) <= 2 # the data and its checksum only
@@ -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"]
File without changes
File without changes