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.
- {python_mobius-0.4.2 → python_mobius-0.4.3}/CHANGELOG.md +10 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/PKG-INFO +1 -1
- {python_mobius-0.4.2 → python_mobius-0.4.3}/pyproject.toml +1 -1
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/__init__.py +1 -1
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/relay.py +43 -5
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_relay.py +86 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/.forgejo/scripts/extract_changelog_section.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/.forgejo/workflows/release.yml +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/.gitignore +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/LICENSE +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/README.md +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/00-overview.md +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/01-ble-transport.md +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/02-framing-and-crc.md +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/03-attributes-and-opcodes.md +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/04-device-identity.md +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/05-scenes.md +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/06-light-schedule.md +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/07-pump-schedule.md +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/08-manufacturer-data.md +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/09-thread-coap-relay.md +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/10-known-gaps-and-open-questions.md +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/11-device-status-attributes.md +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/12-device-identity-and-address-stability.md +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/cli.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/coap.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/constants.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/crc.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/device.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/device_status.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/discovery.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/frame.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/manufacturer.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/mesh_address.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/modifiers.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/power.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/pump_status.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/src/mobius/schedule.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_coap.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_coap_indication_handling.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_connection_cleanup.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_device_status.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_device_summary.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_device_time.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_firmware_versions.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_frame.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_frame_sniffer.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_hyperdrive_device.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_insolation_detection.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_light_intensity_branching.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_light_schedule.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_manufacturer.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_manufacturer_lookup.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_mesh_address.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_mesh_discovery.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_mesh_discovery_direct_connect.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_modifiers.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_networked_thread_devices.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_power.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_pump_schedule.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_pump_status.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_schedule_intensity_scalar.py +0 -0
- {python_mobius-0.4.2 → python_mobius-0.4.3}/tests/test_serial_decoding.py +0 -0
- {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.
|
|
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.
|
|
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" }
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_mobius-0.4.2 → python_mobius-0.4.3}/documentation/10-known-gaps-and-open-questions.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|