python-mobius 0.4.2__tar.gz → 0.4.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 (64) hide show
  1. {python_mobius-0.4.2 → python_mobius-0.4.3}/CHANGELOG.md +10 -0
  2. {python_mobius-0.4.2 → python_mobius-0.4.3}/PKG-INFO +1 -1
  3. {python_mobius-0.4.2 → python_mobius-0.4.3}/pyproject.toml +1 -1
  4. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/__init__.py +1 -1
  5. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/relay.py +43 -5
  6. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_relay.py +86 -0
  7. {python_mobius-0.4.2 → python_mobius-0.4.3}/.forgejo/scripts/extract_changelog_section.py +0 -0
  8. {python_mobius-0.4.2 → python_mobius-0.4.3}/.forgejo/workflows/release.yml +0 -0
  9. {python_mobius-0.4.2 → python_mobius-0.4.3}/.gitignore +0 -0
  10. {python_mobius-0.4.2 → python_mobius-0.4.3}/LICENSE +0 -0
  11. {python_mobius-0.4.2 → python_mobius-0.4.3}/README.md +0 -0
  12. {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/00-overview.md +0 -0
  13. {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/01-ble-transport.md +0 -0
  14. {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/02-framing-and-crc.md +0 -0
  15. {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/03-attributes-and-opcodes.md +0 -0
  16. {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/04-device-identity.md +0 -0
  17. {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/05-scenes.md +0 -0
  18. {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/06-light-schedule.md +0 -0
  19. {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/07-pump-schedule.md +0 -0
  20. {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/08-manufacturer-data.md +0 -0
  21. {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/09-thread-coap-relay.md +0 -0
  22. {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/10-known-gaps-and-open-questions.md +0 -0
  23. {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/11-device-status-attributes.md +0 -0
  24. {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/12-device-identity-and-address-stability.md +0 -0
  25. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/cli.py +0 -0
  26. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/coap.py +0 -0
  27. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/constants.py +0 -0
  28. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/crc.py +0 -0
  29. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/device.py +0 -0
  30. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/device_status.py +0 -0
  31. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/discovery.py +0 -0
  32. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/frame.py +0 -0
  33. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/manufacturer.py +0 -0
  34. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/mesh_address.py +0 -0
  35. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/modifiers.py +0 -0
  36. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/power.py +0 -0
  37. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/pump_status.py +0 -0
  38. {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/schedule.py +0 -0
  39. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_coap.py +0 -0
  40. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_coap_indication_handling.py +0 -0
  41. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_connection_cleanup.py +0 -0
  42. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_device_status.py +0 -0
  43. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_device_summary.py +0 -0
  44. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_device_time.py +0 -0
  45. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_firmware_versions.py +0 -0
  46. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_frame.py +0 -0
  47. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_frame_sniffer.py +0 -0
  48. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_hyperdrive_device.py +0 -0
  49. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_insolation_detection.py +0 -0
  50. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_light_intensity_branching.py +0 -0
  51. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_light_schedule.py +0 -0
  52. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_manufacturer.py +0 -0
  53. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_manufacturer_lookup.py +0 -0
  54. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_mesh_address.py +0 -0
  55. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_mesh_discovery.py +0 -0
  56. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_mesh_discovery_direct_connect.py +0 -0
  57. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_modifiers.py +0 -0
  58. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_networked_thread_devices.py +0 -0
  59. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_power.py +0 -0
  60. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_pump_schedule.py +0 -0
  61. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_pump_status.py +0 -0
  62. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_schedule_intensity_scalar.py +0 -0
  63. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_serial_decoding.py +0 -0
  64. {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_serial_identity.py +0 -0
@@ -1,5 +1,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.3
4
+
5
+ - Fixed relayed devices (anything reached through a gateway over the
6
+ Thread mesh, not directly over BLE) occasionally losing a read for
7
+ no apparent reason, even though the official app kept working fine.
8
+ A single relay round-trip crosses both BLE and the Thread mesh, and
9
+ one dropped or delayed packet somewhere in that longer path turned
10
+ out to be common enough that the app always retries once before
11
+ giving up -- this library now does the same.
12
+
3
13
  ## 0.4.2
4
14
 
5
15
  - Fixed `MeshPeer.age` being parsed as a signed number instead of
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-mobius
3
- Version: 0.4.2
3
+ Version: 0.4.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.4.2"
7
+ version = "0.4.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" }
@@ -65,7 +65,7 @@ from .discovery import (
65
65
  discover_tank,
66
66
  )
67
67
 
68
- __version__ = "0.4.2"
68
+ __version__ = "0.4.3"
69
69
 
70
70
  __all__ = [
71
71
  "__version__",
@@ -177,12 +177,52 @@ class RelayedMobiusDevice(MobiusDevice):
177
177
  embedded in its payload (confirmed via reverse engineering:
178
178
  coapRequest.getToken() == coapResponse.getToken()) -- never by
179
179
  this outer frame's own message ID.
180
+
181
+ Retries exactly once on a timeout before actually raising --
182
+ confirmed via reverse engineering the app's own request-timeout
183
+ handling: it never treats a single timed-out relay round-trip as
184
+ a real failure, always transparently retrying (fresh send, same
185
+ request) once first, only surfacing a failure if the retry ALSO
186
+ times out. A relayed round-trip crosses BLE (to the gateway) and
187
+ then the Thread mesh itself (gateway to target and back) -- a
188
+ single dropped/delayed packet somewhere in that longer path is
189
+ evidently common enough that the app was built assuming it will
190
+ happen regularly, not as an edge case. Each attempt gets its own
191
+ fresh CoAP token (this library generates one per call already,
192
+ not tied to a single request object the way the app's own retry
193
+ reuses one) -- deliberately, so a late, delayed response to the
194
+ first, abandoned attempt can never be mismatched against the
195
+ retry's own, separate wait.
180
196
  """
181
197
  if not self.gateway.is_connected:
182
198
  raise IOError(
183
199
  f"gateway device is not connected -- cannot relay to {self.peer.serial!r} through it"
184
200
  )
185
201
 
202
+ last_timeout_token: Optional[int] = None
203
+ for attempt in (1, 2):
204
+ try:
205
+ return await self._relay_attempt(inner_request_frame, is_retry=(attempt == 2))
206
+ except asyncio.TimeoutError as err:
207
+ last_timeout_token = err.args[0] if err.args else None
208
+ if attempt == 1 and self.debug:
209
+ print(f" [relay debug] timed out waiting {self.timeout}s for "
210
+ f"{self.peer.serial!r} -- retrying once before giving up")
211
+ continue
212
+
213
+ raise IOError(
214
+ f"timed out waiting {self.timeout}s for a relayed response from target "
215
+ f"{self.peer.serial!r} (no CoAP indication received for token {last_timeout_token}, "
216
+ f"even after one retry)"
217
+ )
218
+
219
+ async def _relay_attempt(self, inner_request_frame: bytes, is_retry: bool) -> ParsedFrame:
220
+ """One full send-and-await cycle for _relay() -- split out so
221
+ _relay() itself can retry it once on a timeout without
222
+ duplicating this whole body. Raises asyncio.TimeoutError itself
223
+ (not wrapped in IOError) specifically so _relay() can catch just
224
+ that case to decide whether to retry; every other exception here
225
+ propagates as-is, unwrapped, straight out of _relay() too."""
186
226
  token = next_coap_token()
187
227
  # Confirmed always GET regardless of the inner operation -- see
188
228
  # this module's docstring for why.
@@ -193,7 +233,8 @@ class RelayedMobiusDevice(MobiusDevice):
193
233
  outer_frame = build_frame(OPGROUP_C2CI_REQUEST, COAP_OPCODE, coap_payload, outer_message_id)
194
234
 
195
235
  if self.debug:
196
- print(f" [relay debug] inner request frame ({len(inner_request_frame)}B): "
236
+ retry_note = " (retry)" if is_retry else ""
237
+ print(f" [relay debug] inner request frame{retry_note} ({len(inner_request_frame)}B): "
197
238
  f"{inner_request_frame.hex()}")
198
239
  print(f" [relay debug] target address: {self.peer.address.hex()} token: {token}")
199
240
  print(f" [relay debug] outer request frame ({len(outer_frame)}B): {outer_frame.hex()}")
@@ -220,10 +261,7 @@ class RelayedMobiusDevice(MobiusDevice):
220
261
  coap_response = await asyncio.wait_for(coap_future, timeout=self.timeout)
221
262
  except asyncio.TimeoutError:
222
263
  self.gateway._pending_coap_tokens.pop(token, None)
223
- raise IOError(
224
- f"timed out waiting {self.timeout}s for a relayed response from target "
225
- f"{self.peer.serial!r} (no CoAP indication received for token {token})"
226
- )
264
+ raise asyncio.TimeoutError(token) from None
227
265
  finally:
228
266
  if self.debug:
229
267
  self.gateway.on_any_frame = previous_hook
@@ -133,6 +133,92 @@ class TestRelayedGetAttribute:
133
133
  with pytest.raises(IOError, match="timed out"):
134
134
  await relayed.get_attribute(C2Attribute.Epoch)
135
135
 
136
+ @pytest.mark.asyncio
137
+ async def test_retries_once_on_timeout_then_succeeds(self, connected_gateway):
138
+ """A real, confirmed production issue this addresses directly: a
139
+ relayed round-trip crosses BLE AND the Thread mesh, and a single
140
+ dropped/delayed packet somewhere in that longer path turned out
141
+ to be common enough that two lights lost data within seconds of
142
+ each other from single, transient relay timeouts -- confirmed
143
+ via reverse engineering that the real app never treats one timed-
144
+ out relay attempt as an actual failure, always retrying once
145
+ first. Simulates exactly that: the first attempt's own token
146
+ never gets a response, the retry's own (different) token does."""
147
+ relayed = RelayedMobiusDevice(connected_gateway, PEER, timeout=0.2)
148
+ inner_frame = _build_inner_get_response(C2Attribute.Epoch, index=0, value=struct.pack("<I", 1700000000))
149
+
150
+ call_count = 0
151
+
152
+ async def fake_send_frame(frame, message_id, wait_response=True, timeout=5.0):
153
+ nonlocal call_count
154
+ call_count += 1
155
+ assert wait_response is False
156
+ if call_count == 1:
157
+ return None # first attempt: nothing ever arrives for its token
158
+ # second attempt (the retry): deliver the real response for
159
+ # ITS OWN token -- confirms each attempt truly gets a fresh
160
+ # one, not a reused/stale one from the first, abandoned try.
161
+ token = _extract_sent_token(frame)
162
+ indication_bytes = _build_indication_frame_bytes(inner_frame, token)
163
+ connected_gateway._on_rx_final(None, bytearray(indication_bytes))
164
+ return None
165
+
166
+ connected_gateway._send_frame = fake_send_frame
167
+
168
+ result = await relayed.get_attribute(C2Attribute.Epoch)
169
+
170
+ assert result == [struct.pack("<I", 1700000000)]
171
+ assert call_count == 2 # confirms a retry genuinely happened, not just luck
172
+
173
+ @pytest.mark.asyncio
174
+ async def test_raises_only_after_both_attempts_time_out(self, connected_gateway):
175
+ """The other half of the retry behavior: it's a single retry,
176
+ not unlimited -- if the retry ALSO times out, this must still
177
+ raise (not hang or retry forever), and the error message should
178
+ reflect that a retry was already attempted."""
179
+ relayed = RelayedMobiusDevice(connected_gateway, PEER, timeout=0.2)
180
+
181
+ call_count = 0
182
+
183
+ async def fake_send_frame(frame, message_id, wait_response=True, timeout=5.0):
184
+ nonlocal call_count
185
+ call_count += 1
186
+ return None # never arrives, on either attempt
187
+
188
+ connected_gateway._send_frame = fake_send_frame
189
+
190
+ with pytest.raises(IOError, match="even after one retry"):
191
+ await relayed.get_attribute(C2Attribute.Epoch)
192
+
193
+ assert call_count == 2 # confirms exactly one retry happened, not zero or unlimited
194
+
195
+ @pytest.mark.asyncio
196
+ async def test_does_not_retry_a_malformed_response(self, connected_gateway):
197
+ """Only a TIMEOUT gets retried -- a response that actually
198
+ arrived, just malformed, is a different failure mode a retry
199
+ wouldn't help with (the same bad data would just arrive again),
200
+ so this should raise immediately, not after a second attempt."""
201
+ relayed = RelayedMobiusDevice(connected_gateway, PEER, timeout=0.2)
202
+
203
+ call_count = 0
204
+
205
+ async def fake_send_frame(frame, message_id, wait_response=True, timeout=5.0):
206
+ nonlocal call_count
207
+ call_count += 1
208
+ token = _extract_sent_token(frame)
209
+ # A real indication frame, but with garbage as the inner
210
+ # FSCI payload -- parse_frame() will reject this (bad CRC).
211
+ indication_bytes = _build_indication_frame_bytes(b"\x00\x00\x00\x00", token)
212
+ connected_gateway._on_rx_final(None, bytearray(indication_bytes))
213
+ return None
214
+
215
+ connected_gateway._send_frame = fake_send_frame
216
+
217
+ with pytest.raises(IOError, match="malformed"):
218
+ await relayed.get_attribute(C2Attribute.Epoch)
219
+
220
+ assert call_count == 1 # confirms no retry was attempted for this failure mode
221
+
136
222
  @pytest.mark.asyncio
137
223
  async def test_ignores_the_meaningless_opcode_25_confirm(self, connected_gateway):
138
224
  """Confirms a bare opcode-25 confirm (matching the real, confirmed
File without changes
File without changes
File without changes