python-mobius 0.4.1__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.1 → python_mobius-0.4.3}/CHANGELOG.md +26 -2
- {python_mobius-0.4.1 → python_mobius-0.4.3}/PKG-INFO +2 -2
- {python_mobius-0.4.1 → python_mobius-0.4.3}/documentation/09-thread-coap-relay.md +6 -3
- {python_mobius-0.4.1 → python_mobius-0.4.3}/pyproject.toml +1 -1
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/__init__.py +1 -1
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/device.py +12 -4
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/device_status.py +15 -6
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/relay.py +43 -5
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/schedule.py +10 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_device_summary.py +1 -1
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_light_schedule.py +10 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_manufacturer.py +12 -9
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_mesh_discovery.py +17 -17
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_mesh_discovery_direct_connect.py +15 -15
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_networked_thread_devices.py +62 -31
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_pump_schedule.py +16 -1
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_relay.py +87 -1
- python_mobius-0.4.3/tests/test_serial_decoding.py +22 -0
- python_mobius-0.4.1/tests/test_serial_decoding.py +0 -20
- {python_mobius-0.4.1 → python_mobius-0.4.3}/.forgejo/scripts/extract_changelog_section.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/.forgejo/workflows/release.yml +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/.gitignore +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/LICENSE +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/README.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/documentation/00-overview.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/documentation/01-ble-transport.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/documentation/02-framing-and-crc.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/documentation/03-attributes-and-opcodes.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/documentation/04-device-identity.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/documentation/05-scenes.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/documentation/06-light-schedule.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/documentation/07-pump-schedule.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/documentation/08-manufacturer-data.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/documentation/10-known-gaps-and-open-questions.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/documentation/11-device-status-attributes.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/documentation/12-device-identity-and-address-stability.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/cli.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/coap.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/constants.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/crc.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/discovery.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/frame.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/manufacturer.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/mesh_address.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/modifiers.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/power.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/src/mobius/pump_status.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_coap.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_coap_indication_handling.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_connection_cleanup.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_device_status.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_device_time.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_firmware_versions.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_frame.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_frame_sniffer.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_hyperdrive_device.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_insolation_detection.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_light_intensity_branching.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_manufacturer_lookup.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_mesh_address.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_modifiers.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_power.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_pump_status.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_schedule_intensity_scalar.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.3}/tests/test_serial_identity.py +0 -0
|
@@ -1,5 +1,29 @@
|
|
|
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
|
+
|
|
13
|
+
## 0.4.2
|
|
14
|
+
|
|
15
|
+
- Fixed `MeshPeer.age` being parsed as a signed number instead of
|
|
16
|
+
unsigned -- could have shown as negative for a peer that had been
|
|
17
|
+
unreachable a very long time. Also confirmed what this value
|
|
18
|
+
actually means: milliseconds since that peer was last heard from on
|
|
19
|
+
the mesh, not since it was first found. It resets whenever the mesh
|
|
20
|
+
hears from that device again, so seeing it go down between two
|
|
21
|
+
checks is expected, not a bug.
|
|
22
|
+
- Fixed `mobius-scan --dump-schedule` crashing for pumps specifically
|
|
23
|
+
(`'PumpSchedulePoint' object has no attribute 'has'`) -- that class
|
|
24
|
+
was missing a method `SchedulePoint` (the light-schedule equivalent)
|
|
25
|
+
already had, despite sharing the exact same flag bits.
|
|
26
|
+
|
|
3
27
|
## 0.4.1
|
|
4
28
|
|
|
5
29
|
- Fixed `discover_tank()` sometimes reporting the connected device
|
|
@@ -634,8 +658,8 @@ branch has been merged into main as part of this release.
|
|
|
634
658
|
Added while building a Home Assistant integration on top of this library,
|
|
635
659
|
which needs a manufacturer string for its device registry.
|
|
636
660
|
- `get_device_info()`'s `"serial"` field is now decoded as ASCII text
|
|
637
|
-
instead of hex-encoded raw bytes (e.g. `"
|
|
638
|
-
`"
|
|
661
|
+
instead of hex-encoded raw bytes (e.g. `"FAKESERIAL0001"` instead of
|
|
662
|
+
`"46414b4553455249414c30303031"`). Confirmed printable ASCII on every
|
|
639
663
|
device tested, via cross-validation against the same serial parsed from
|
|
640
664
|
BLE advertisement manufacturer data. Falls back to hex for any
|
|
641
665
|
non-printable-ASCII edge case not yet observed. Found while building the
|
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
Metadata-Version: 2.
|
|
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
|
|
@@ -173,9 +173,12 @@ logic to be checked via a generic support check, **not gated to a
|
|
|
173
173
|
Cowboy hub** the way the older three-parallel-arrays mechanism is.
|
|
174
174
|
`MLPrefix` returns 24 bytes, of which only the first 8 are the real
|
|
175
175
|
prefix; each `NetworkedThreadDevices` element is 28 bytes: an 8-byte
|
|
176
|
-
interface-identifier suffix, a 4-byte "age" value
|
|
177
|
-
|
|
178
|
-
|
|
176
|
+
interface-identifier suffix, a 4-byte unsigned "age" value in
|
|
177
|
+
milliseconds -- confirmed via reverse engineering the app's own network-
|
|
178
|
+
troubleshooting screen to mean time since that specific peer was last
|
|
179
|
+
heard from over the mesh (a resetting value, not time since first
|
|
180
|
+
discovered -- see `MeshPeer`'s own docstring for the full detail), a
|
|
181
|
+
2-byte model ID, and a 14-byte ASCII
|
|
179
182
|
serial -- the same serial format used by `SerialNumberArray`. Every peer
|
|
180
183
|
returned shares the connected device's own `MLPrefix`, which in
|
|
181
184
|
practice is "every device in the same aquarium" -- the closest local
|
|
@@ -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" }
|
|
@@ -843,9 +843,11 @@ class MobiusDevice:
|
|
|
843
843
|
build_rloc_address(), which is for the different case of only
|
|
844
844
|
having a 2-byte short address to encode into the prefix's last 2
|
|
845
845
|
bytes -- here the full 8-byte suffix is already present in the
|
|
846
|
-
entry, so it's used as-is); bytes 8-11 are a 4-byte
|
|
847
|
-
|
|
848
|
-
|
|
846
|
+
entry, so it's used as-is); bytes 8-11 are a 4-byte unsigned
|
|
847
|
+
"age" value in milliseconds -- confirmed to mean time since this
|
|
848
|
+
peer was last heard from over the mesh, a resetting value, not
|
|
849
|
+
time since first discovered (see MeshPeer's own docstring for
|
|
850
|
+
the full detail); bytes 12-13 are the 2-byte model
|
|
849
851
|
ID; bytes 14-27 are the 14-byte ASCII serial number, matching the
|
|
850
852
|
SerialNumberArray format used elsewhere in this library. All
|
|
851
853
|
multi-byte numeric fields are read little-endian -- confirmed
|
|
@@ -888,7 +890,13 @@ class MobiusDevice:
|
|
|
888
890
|
if len(entry) != 28:
|
|
889
891
|
continue
|
|
890
892
|
address = prefix + entry[0:8]
|
|
891
|
-
|
|
893
|
+
# Unsigned -- confirmed via reverse engineering the app's own
|
|
894
|
+
# network-troubleshooting screen, which explicitly treats this
|
|
895
|
+
# same 4-byte field as an unsigned value before ever using it
|
|
896
|
+
# for anything else. See MeshPeer's own docstring for the full,
|
|
897
|
+
# now-confirmed meaning (milliseconds since this peer was last
|
|
898
|
+
# heard from over the mesh).
|
|
899
|
+
age = struct.unpack("<I", entry[8:12])[0]
|
|
892
900
|
model_raw = struct.unpack("<h", entry[12:14])[0]
|
|
893
901
|
try:
|
|
894
902
|
serial = entry[14:28].decode("ascii").rstrip("\x00")
|
|
@@ -130,18 +130,27 @@ class MeshPeer:
|
|
|
130
130
|
`age` is only ever set by discover_networked_thread_devices() --
|
|
131
131
|
None from every other source of MeshPeer, including
|
|
132
132
|
discover_mesh_peers() and discover_mesh_peers_via_direct_connect().
|
|
133
|
-
Confirmed
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
133
|
+
Confirmed via reverse engineering the app's own network-
|
|
134
|
+
troubleshooting screen: milliseconds since this specific peer was
|
|
135
|
+
last heard from over the Thread mesh, as far as the connected
|
|
136
|
+
gateway currently knows -- NOT time since it was first discovered
|
|
137
|
+
or added to the tank. A RESETTING value, not a monotonic one: it
|
|
138
|
+
legitimately drops back toward zero whenever the mesh genuinely
|
|
139
|
+
receives fresh activity from that peer, which happens on its own
|
|
140
|
+
schedule, independent of whenever this is read. Two checks in
|
|
141
|
+
quick succession can validly show a SMALLER age the second time --
|
|
142
|
+
that's expected, not a parsing error. The app's own troubleshooting
|
|
143
|
+
screen treats anything over roughly 5 minutes (300000) as long
|
|
144
|
+
enough to flag that peer as currently unreachable/failed, which is
|
|
145
|
+
a reasonable rule of thumb for interpreting this value generally,
|
|
146
|
+
though not enforced by this library itself.
|
|
138
147
|
"""
|
|
139
148
|
serial: str
|
|
140
149
|
model_raw: int
|
|
141
150
|
model: Optional[Model] # None if model_raw doesn't match a known Model value
|
|
142
151
|
short_address: int
|
|
143
152
|
address: bytes # 16-byte Thread mesh-local IPv6, ready to pass to encode_coap_request()
|
|
144
|
-
age: Optional[int] = None #
|
|
153
|
+
age: Optional[int] = None # milliseconds since last heard from on the mesh -- see docstring above
|
|
145
154
|
|
|
146
155
|
|
|
147
156
|
@dataclass
|
|
@@ -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
|
|
@@ -235,6 +235,16 @@ class PumpSchedulePoint:
|
|
|
235
235
|
return None
|
|
236
236
|
return cls(time_minutes, flags, PumpPrimitiveValue.parse(data[3:]))
|
|
237
237
|
|
|
238
|
+
def has(self, flag: int) -> bool:
|
|
239
|
+
# Same flags field, same bit meanings as SchedulePoint's own
|
|
240
|
+
# has() (see this class's own docstring: identical wire format,
|
|
241
|
+
# just different primitiveData) -- an earlier version of this
|
|
242
|
+
# class omitted this entirely, which crashed mobius.cli's own
|
|
243
|
+
# --dump-schedule for pumps specifically (its own _format_flags()
|
|
244
|
+
# helper is shared between the light and pump branches, calling
|
|
245
|
+
# point.has() either way).
|
|
246
|
+
return (self.flags & flag) == flag
|
|
247
|
+
|
|
238
248
|
|
|
239
249
|
def get_active_pump_block(points: list[PumpSchedulePoint], minute_of_day: int) -> Optional[PumpSchedulePoint]:
|
|
240
250
|
"""
|
|
@@ -120,7 +120,7 @@ async def test_get_device_summary_includes_generic_fields_for_lights_too():
|
|
|
120
120
|
responses = {
|
|
121
121
|
C2Attribute.Model: [(179).to_bytes(2, "little", signed=True)],
|
|
122
122
|
C2Attribute.Name: [b""],
|
|
123
|
-
C2Attribute.SerialNumber: [b"
|
|
123
|
+
C2Attribute.SerialNumber: [b"FAKESERIAL0001"],
|
|
124
124
|
C2Attribute.PrimitiveType: [bytes([1])], # VisualV1
|
|
125
125
|
C2Attribute.ErrorState: [(0).to_bytes(2, "little", signed=True)],
|
|
126
126
|
C2Attribute.MACAddress: [],
|
|
@@ -25,6 +25,16 @@ def test_schedule_point_parses_time_flags_and_primitive():
|
|
|
25
25
|
assert sp.light.get(VisualID.RoyalBlue) == 500
|
|
26
26
|
|
|
27
27
|
|
|
28
|
+
def test_has_reads_individual_flag_bits():
|
|
29
|
+
# FLAG_ACTIVE=1 | FLAG_NIGHT=2 = 3
|
|
30
|
+
night_point = SchedulePoint(600, 3, LightPrimitive({}))
|
|
31
|
+
assert night_point.has(SchedulePoint.FLAG_NIGHT) is True
|
|
32
|
+
assert night_point.has(SchedulePoint.FLAG_SUNRISE) is False
|
|
33
|
+
|
|
34
|
+
day_point = SchedulePoint(600, 1, LightPrimitive({}))
|
|
35
|
+
assert day_point.has(SchedulePoint.FLAG_NIGHT) is False
|
|
36
|
+
|
|
37
|
+
|
|
28
38
|
def test_inactive_point_returns_none():
|
|
29
39
|
raw_channel = bytes([18]) + (500).to_bytes(2, "little")
|
|
30
40
|
raw_point = (600).to_bytes(2, "little") + bytes([0]) + raw_channel # flags=0, not ACTIVE
|
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
"""
|
|
2
|
-
Manufacturer-data parsing tests, using
|
|
3
|
-
from four devices (two VorTech pumps, two
|
|
4
|
-
one tank -- see documentation/
|
|
2
|
+
Manufacturer-data parsing tests, using payloads shaped after real
|
|
3
|
+
advertisement bytes captured from four devices (two VorTech pumps, two
|
|
4
|
+
Radion XR15 lights) all sharing one tank -- see documentation/
|
|
5
|
+
08-manufacturer-data.md. Model/flags/pan_id bytes are the genuine,
|
|
6
|
+
validated wire format; each serial has been swapped for an obviously-
|
|
7
|
+
fake placeholder of the same byte length.
|
|
5
8
|
"""
|
|
6
9
|
from mobius import parse_manufacturer_data, Model
|
|
7
10
|
|
|
8
11
|
REAL_PAYLOADS = {
|
|
9
|
-
"pump_left": "
|
|
10
|
-
"pump_right": "
|
|
11
|
-
"light_1": "
|
|
12
|
-
"light_2": "
|
|
12
|
+
"pump_left": "2a0001000000000f3d3030303030303030303030303032",
|
|
13
|
+
"pump_right": "2a0001000000000f3d3030303030303030303030303031",
|
|
14
|
+
"light_1": "b30001000000000f3d46414b4553455249414c30303031",
|
|
15
|
+
"light_2": "b30001000000000f3d46414b4553455249414c30303032",
|
|
13
16
|
}
|
|
14
17
|
|
|
15
18
|
|
|
@@ -17,7 +20,7 @@ def test_parses_pump_payload():
|
|
|
17
20
|
info = parse_manufacturer_data(bytes.fromhex(REAL_PAYLOADS["pump_left"]))
|
|
18
21
|
assert info is not None
|
|
19
22
|
assert info.model == Model.VorTechMP40wG3QD
|
|
20
|
-
assert info.serial == "
|
|
23
|
+
assert info.serial == "00000000000002"
|
|
21
24
|
assert info.pan_id == 0x3D0F
|
|
22
25
|
|
|
23
26
|
|
|
@@ -25,7 +28,7 @@ def test_parses_light_payload():
|
|
|
25
28
|
info = parse_manufacturer_data(bytes.fromhex(REAL_PAYLOADS["light_1"]))
|
|
26
29
|
assert info is not None
|
|
27
30
|
assert info.model == Model.RadionXR15wG6Pro
|
|
28
|
-
assert info.serial == "
|
|
31
|
+
assert info.serial == "FAKESERIAL0001"
|
|
29
32
|
assert info.pan_id == 0x3D0F
|
|
30
33
|
|
|
31
34
|
|
|
@@ -44,7 +44,7 @@ async def test_discover_mesh_peers_basic():
|
|
|
44
44
|
if attr_id == C2Attribute.MeshLocalAddresses:
|
|
45
45
|
return [OWN_ADDRESS]
|
|
46
46
|
if attr_id == C2Attribute.SerialNumberArray:
|
|
47
|
-
return [_serial_bytes("
|
|
47
|
+
return [_serial_bytes("00000000000001"), _serial_bytes("FAKESERIAL0001")]
|
|
48
48
|
if attr_id == C2Attribute.DeviceModelArray:
|
|
49
49
|
return [_model_bytes(Model.VorTechMP40wG3QD), _model_bytes(Model.RadionXR15wG6Pro)]
|
|
50
50
|
if attr_id == C2Attribute.ShortAddressArray:
|
|
@@ -56,12 +56,12 @@ async def test_discover_mesh_peers_basic():
|
|
|
56
56
|
|
|
57
57
|
assert len(peers) == 2
|
|
58
58
|
|
|
59
|
-
assert peers[0].serial == "
|
|
59
|
+
assert peers[0].serial == "00000000000001"
|
|
60
60
|
assert peers[0].model == Model.VorTechMP40wG3QD
|
|
61
61
|
assert peers[0].short_address == 0x1234
|
|
62
62
|
assert peers[0].address == build_rloc_address(PREFIX, 0x1234)
|
|
63
63
|
|
|
64
|
-
assert peers[1].serial == "
|
|
64
|
+
assert peers[1].serial == "FAKESERIAL0001"
|
|
65
65
|
assert peers[1].model == Model.RadionXR15wG6Pro
|
|
66
66
|
assert peers[1].short_address == 0x5678
|
|
67
67
|
assert peers[1].address == build_rloc_address(PREFIX, 0x5678)
|
|
@@ -81,9 +81,9 @@ async def test_discover_mesh_peers_filters_empty_slots():
|
|
|
81
81
|
return [OWN_ADDRESS]
|
|
82
82
|
if attr_id == C2Attribute.SerialNumberArray:
|
|
83
83
|
return [
|
|
84
|
-
_serial_bytes("
|
|
84
|
+
_serial_bytes("00000000000001"),
|
|
85
85
|
_serial_bytes("00000000000000"), # cleared slot
|
|
86
|
-
_serial_bytes("
|
|
86
|
+
_serial_bytes("FAKESERIAL0001"),
|
|
87
87
|
]
|
|
88
88
|
if attr_id == C2Attribute.DeviceModelArray:
|
|
89
89
|
return [_model_bytes(Model.VorTechMP40wG3QD), b"\x00\x00", _model_bytes(Model.RadionXR15wG6Pro)]
|
|
@@ -95,7 +95,7 @@ async def test_discover_mesh_peers_filters_empty_slots():
|
|
|
95
95
|
peers = await device.discover_mesh_peers()
|
|
96
96
|
|
|
97
97
|
assert len(peers) == 2
|
|
98
|
-
assert {p.serial for p in peers} == {"
|
|
98
|
+
assert {p.serial for p in peers} == {"00000000000001", "FAKESERIAL0001"}
|
|
99
99
|
|
|
100
100
|
|
|
101
101
|
@pytest.mark.asyncio
|
|
@@ -106,7 +106,7 @@ async def test_discover_mesh_peers_unknown_model_returns_none_not_exception():
|
|
|
106
106
|
if attr_id == C2Attribute.MeshLocalAddresses:
|
|
107
107
|
return [OWN_ADDRESS]
|
|
108
108
|
if attr_id == C2Attribute.SerialNumberArray:
|
|
109
|
-
return [_serial_bytes("
|
|
109
|
+
return [_serial_bytes("00000000000001")]
|
|
110
110
|
if attr_id == C2Attribute.DeviceModelArray:
|
|
111
111
|
return [struct.pack("<h", 9999)] # not a recognized Model value
|
|
112
112
|
if attr_id == C2Attribute.ShortAddressArray:
|
|
@@ -162,7 +162,7 @@ async def test_discover_mesh_peers_array_fetch_failure_returns_empty():
|
|
|
162
162
|
if attr_id == C2Attribute.MeshLocalAddresses:
|
|
163
163
|
return [OWN_ADDRESS]
|
|
164
164
|
if attr_id == C2Attribute.SerialNumberArray:
|
|
165
|
-
return [_serial_bytes("
|
|
165
|
+
return [_serial_bytes("00000000000001")]
|
|
166
166
|
raise IOError("array not supported")
|
|
167
167
|
|
|
168
168
|
device.get_attribute = fake_get_attribute
|
|
@@ -267,7 +267,7 @@ async def test_discover_tank_basic():
|
|
|
267
267
|
device = MobiusDevice(_fake_ble_device())
|
|
268
268
|
own_address = PREFIX + bytes.fromhex("000000fffe000001")
|
|
269
269
|
other_peer = MeshPeer(
|
|
270
|
-
serial="
|
|
270
|
+
serial="00000000000001", model_raw=Model.VorTechMP40wG3QD.value,
|
|
271
271
|
model=Model.VorTechMP40wG3QD, short_address=0x1234,
|
|
272
272
|
address=PREFIX + bytes.fromhex("000000fffe001234"),
|
|
273
273
|
)
|
|
@@ -275,7 +275,7 @@ async def test_discover_tank_basic():
|
|
|
275
275
|
async def fake_get_attribute(attr_id, index=0, count=1):
|
|
276
276
|
if attr_id == C2Attribute.MeshLocalAddresses:
|
|
277
277
|
return [own_address]
|
|
278
|
-
info_attrs = _device_info_attrs(Model.RadionXR15wG6Pro, "
|
|
278
|
+
info_attrs = _device_info_attrs(Model.RadionXR15wG6Pro, "FAKESERIAL0002")
|
|
279
279
|
if attr_id in info_attrs:
|
|
280
280
|
return info_attrs[attr_id]
|
|
281
281
|
raise Exception("FsciStatus.UnsupportedAttribute") # forces discover_mesh_peers_auto()'s fallback to also fail
|
|
@@ -289,7 +289,7 @@ async def test_discover_tank_basic():
|
|
|
289
289
|
assert tank.prefix == PREFIX
|
|
290
290
|
assert len(tank.peers) == 2
|
|
291
291
|
# the connected device itself comes first
|
|
292
|
-
assert tank.peers[0].serial == "
|
|
292
|
+
assert tank.peers[0].serial == "FAKESERIAL0002"
|
|
293
293
|
assert tank.peers[0].model == Model.RadionXR15wG6Pro
|
|
294
294
|
assert tank.peers[0].address == own_address
|
|
295
295
|
assert tank.peers[1] is other_peer
|
|
@@ -314,7 +314,7 @@ async def test_discover_tank_deduplicates_self_from_discover_mesh_peers_auto():
|
|
|
314
314
|
genuinely different peers are unaffected.
|
|
315
315
|
"""
|
|
316
316
|
device = MobiusDevice(_fake_ble_device())
|
|
317
|
-
own_serial = "
|
|
317
|
+
own_serial = "00000000000002"
|
|
318
318
|
own_address = PREFIX + bytes.fromhex("000000fffe005786")
|
|
319
319
|
|
|
320
320
|
# What discover_mesh_peers_auto() reports for the connected device's
|
|
@@ -328,8 +328,8 @@ async def test_discover_tank_deduplicates_self_from_discover_mesh_peers_auto():
|
|
|
328
328
|
address=own_address, age=14519,
|
|
329
329
|
)
|
|
330
330
|
genuinely_other_peer = MeshPeer(
|
|
331
|
-
serial="
|
|
332
|
-
model=Model.VorTechMP40wG3QD, short_address=
|
|
331
|
+
serial="00000000000001", model_raw=Model.VorTechMP40wG3QD.value,
|
|
332
|
+
model=Model.VorTechMP40wG3QD, short_address=0x0200,
|
|
333
333
|
address=PREFIX + bytes.fromhex("000000fffe009a0b"), age=373,
|
|
334
334
|
)
|
|
335
335
|
|
|
@@ -354,7 +354,7 @@ async def test_discover_tank_deduplicates_self_from_discover_mesh_peers_auto():
|
|
|
354
354
|
serials = [p.serial for p in tank.peers]
|
|
355
355
|
assert serials.count(own_serial) == 1
|
|
356
356
|
assert own_serial in serials
|
|
357
|
-
assert "
|
|
357
|
+
assert "00000000000001" in serials
|
|
358
358
|
# The connected device's own directly-built entry wins -- confirmed
|
|
359
359
|
# by checking it's the exact instance discover_tank() constructs
|
|
360
360
|
# itself (has no .age set, unlike self_reported_by_auto which does),
|
|
@@ -399,7 +399,7 @@ async def test_discover_tank_single_device_no_other_peers():
|
|
|
399
399
|
async def fake_get_attribute(attr_id, index=0, count=1):
|
|
400
400
|
if attr_id == C2Attribute.MeshLocalAddresses:
|
|
401
401
|
return [own_address]
|
|
402
|
-
info_attrs = _device_info_attrs(Model.VorTechMP40wG3QD, "
|
|
402
|
+
info_attrs = _device_info_attrs(Model.VorTechMP40wG3QD, "00000000000001")
|
|
403
403
|
if attr_id in info_attrs:
|
|
404
404
|
return info_attrs[attr_id]
|
|
405
405
|
raise Exception("FsciStatus.UnsupportedAttribute")
|
|
@@ -410,4 +410,4 @@ async def test_discover_tank_single_device_no_other_peers():
|
|
|
410
410
|
tank = await discover_tank(device)
|
|
411
411
|
assert tank.prefix == PREFIX
|
|
412
412
|
assert len(tank.peers) == 1
|
|
413
|
-
assert tank.peers[0].serial == "
|
|
413
|
+
assert tank.peers[0].serial == "00000000000001"
|
|
@@ -36,13 +36,13 @@ def _info(serial: str, model: Model) -> MobiusAdvertisement:
|
|
|
36
36
|
@pytest.mark.asyncio
|
|
37
37
|
async def test_basic_discovery():
|
|
38
38
|
devices = [
|
|
39
|
-
(_ble("AA:AA:AA:AA:AA:AA"), _info("
|
|
40
|
-
(_ble("BB:BB:BB:BB:BB:BB"), _info("
|
|
39
|
+
(_ble("AA:AA:AA:AA:AA:AA"), _info("00000000000001", Model.VorTechMP40wG3QD)),
|
|
40
|
+
(_ble("BB:BB:BB:BB:BB:BB"), _info("FAKESERIAL0001", Model.RadionXR15wG6Pro)),
|
|
41
41
|
]
|
|
42
42
|
|
|
43
43
|
addresses = {
|
|
44
|
-
"
|
|
45
|
-
"
|
|
44
|
+
"00000000000001": build_rloc_address(PREFIX, 0x1234),
|
|
45
|
+
"FAKESERIAL0001": build_rloc_address(PREFIX, 0x5678),
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
async def fake_connect(self):
|
|
@@ -67,17 +67,17 @@ async def test_basic_discovery():
|
|
|
67
67
|
|
|
68
68
|
assert len(peers) == 2
|
|
69
69
|
by_serial = {p.serial: p for p in peers}
|
|
70
|
-
assert by_serial["
|
|
71
|
-
assert by_serial["
|
|
72
|
-
assert by_serial["
|
|
73
|
-
assert by_serial["
|
|
70
|
+
assert by_serial["00000000000001"].address == addresses["00000000000001"]
|
|
71
|
+
assert by_serial["00000000000001"].model == Model.VorTechMP40wG3QD
|
|
72
|
+
assert by_serial["FAKESERIAL0001"].address == addresses["FAKESERIAL0001"]
|
|
73
|
+
assert by_serial["FAKESERIAL0001"].model == Model.RadionXR15wG6Pro
|
|
74
74
|
|
|
75
75
|
|
|
76
76
|
@pytest.mark.asyncio
|
|
77
77
|
async def test_skips_devices_with_no_parsed_info():
|
|
78
78
|
devices = [
|
|
79
79
|
(_ble("AA:AA:AA:AA:AA:AA"), None), # no manufacturer data parsed -- no serial to key on
|
|
80
|
-
(_ble("BB:BB:BB:BB:BB:BB"), _info("
|
|
80
|
+
(_ble("BB:BB:BB:BB:BB:BB"), _info("FAKESERIAL0001", Model.RadionXR15wG6Pro)),
|
|
81
81
|
]
|
|
82
82
|
|
|
83
83
|
async def fake_connect(self):
|
|
@@ -95,14 +95,14 @@ async def test_skips_devices_with_no_parsed_info():
|
|
|
95
95
|
peers = await discover_mesh_peers_via_direct_connect(devices)
|
|
96
96
|
|
|
97
97
|
assert len(peers) == 1
|
|
98
|
-
assert peers[0].serial == "
|
|
98
|
+
assert peers[0].serial == "FAKESERIAL0001"
|
|
99
99
|
|
|
100
100
|
|
|
101
101
|
@pytest.mark.asyncio
|
|
102
102
|
async def test_skips_devices_that_fail_to_connect():
|
|
103
103
|
devices = [
|
|
104
|
-
(_ble("AA:AA:AA:AA:AA:AA"), _info("
|
|
105
|
-
(_ble("BB:BB:BB:BB:BB:BB"), _info("
|
|
104
|
+
(_ble("AA:AA:AA:AA:AA:AA"), _info("00000000000001", Model.VorTechMP40wG3QD)),
|
|
105
|
+
(_ble("BB:BB:BB:BB:BB:BB"), _info("FAKESERIAL0001", Model.RadionXR15wG6Pro)),
|
|
106
106
|
]
|
|
107
107
|
|
|
108
108
|
async def fake_connect(self):
|
|
@@ -125,12 +125,12 @@ async def test_skips_devices_that_fail_to_connect():
|
|
|
125
125
|
# matches this library's usual fail-soft philosophy for this kind of
|
|
126
126
|
# best-effort, multi-device operation.
|
|
127
127
|
assert len(peers) == 1
|
|
128
|
-
assert peers[0].serial == "
|
|
128
|
+
assert peers[0].serial == "FAKESERIAL0001"
|
|
129
129
|
|
|
130
130
|
|
|
131
131
|
@pytest.mark.asyncio
|
|
132
132
|
async def test_skips_devices_that_report_no_address():
|
|
133
|
-
devices = [(_ble("AA:AA:AA:AA:AA:AA"), _info("
|
|
133
|
+
devices = [(_ble("AA:AA:AA:AA:AA:AA"), _info("00000000000001", Model.VorTechMP40wG3QD))]
|
|
134
134
|
|
|
135
135
|
async def fake_connect(self):
|
|
136
136
|
self._client = object()
|
|
@@ -160,7 +160,7 @@ async def test_disconnects_even_after_successful_read():
|
|
|
160
160
|
"""Confirms each connection is actually closed (via the async with
|
|
161
161
|
block) rather than leaked, since this connects to potentially many
|
|
162
162
|
devices in a loop."""
|
|
163
|
-
devices = [(_ble("AA:AA:AA:AA:AA:AA"), _info("
|
|
163
|
+
devices = [(_ble("AA:AA:AA:AA:AA:AA"), _info("00000000000001", Model.VorTechMP40wG3QD))]
|
|
164
164
|
disconnect_called = {"count": 0}
|
|
165
165
|
|
|
166
166
|
async def fake_connect(self):
|
|
@@ -31,7 +31,7 @@ MLPREFIX_RAW = PREFIX + bytes.fromhex("00" * 16)
|
|
|
31
31
|
def _entry(suffix: bytes, age: int, model: Model, serial: str) -> bytes:
|
|
32
32
|
assert len(suffix) == 8
|
|
33
33
|
serial_bytes = serial.encode("ascii").ljust(14, b"\x00")
|
|
34
|
-
return suffix + struct.pack("<
|
|
34
|
+
return suffix + struct.pack("<I", age) + struct.pack("<h", model.value) + serial_bytes
|
|
35
35
|
|
|
36
36
|
|
|
37
37
|
@pytest.mark.asyncio
|
|
@@ -45,8 +45,8 @@ async def test_discover_networked_thread_devices_basic():
|
|
|
45
45
|
return [MLPREFIX_RAW]
|
|
46
46
|
if attr_id == C2Attribute.NetworkedThreadDevices:
|
|
47
47
|
return [
|
|
48
|
-
_entry(suffix_a, 42, Model.VorTechMP40wG3QD, "
|
|
49
|
-
_entry(suffix_b, 7, Model.RadionXR15wG6Pro, "
|
|
48
|
+
_entry(suffix_a, 42, Model.VorTechMP40wG3QD, "00000000000001"),
|
|
49
|
+
_entry(suffix_b, 7, Model.RadionXR15wG6Pro, "FAKESERIAL0001"),
|
|
50
50
|
]
|
|
51
51
|
raise AssertionError(f"unexpected attribute: {attr_id}")
|
|
52
52
|
|
|
@@ -55,19 +55,46 @@ async def test_discover_networked_thread_devices_basic():
|
|
|
55
55
|
|
|
56
56
|
assert len(peers) == 2
|
|
57
57
|
|
|
58
|
-
assert peers[0].serial == "
|
|
58
|
+
assert peers[0].serial == "00000000000001"
|
|
59
59
|
assert peers[0].model == Model.VorTechMP40wG3QD
|
|
60
60
|
assert peers[0].address == PREFIX + suffix_a
|
|
61
61
|
assert peers[0].short_address == 0x1234
|
|
62
62
|
assert peers[0].age == 42
|
|
63
63
|
|
|
64
|
-
assert peers[1].serial == "
|
|
64
|
+
assert peers[1].serial == "FAKESERIAL0001"
|
|
65
65
|
assert peers[1].model == Model.RadionXR15wG6Pro
|
|
66
66
|
assert peers[1].address == PREFIX + suffix_b
|
|
67
67
|
assert peers[1].short_address == 0x5678
|
|
68
68
|
assert peers[1].age == 7
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
@pytest.mark.asyncio
|
|
72
|
+
async def test_discover_networked_thread_devices_age_is_unsigned():
|
|
73
|
+
"""The real, confirmed bug this guards against: an earlier version
|
|
74
|
+
of this parsing read age as a SIGNED 32-bit integer. Confirmed via
|
|
75
|
+
reverse engineering the app's own network-troubleshooting screen
|
|
76
|
+
that it's actually unsigned -- a value with the top bit set (e.g.
|
|
77
|
+
0x80000001) must come back as the large positive number the app
|
|
78
|
+
itself would show, not a negative one."""
|
|
79
|
+
device = MobiusDevice(_fake_ble_device())
|
|
80
|
+
suffix = bytes.fromhex("000000fffe00") + struct.pack("<H", 0x1234)
|
|
81
|
+
high_bit_age = 0x80000001 # would decode as -2147483647 if parsed signed
|
|
82
|
+
|
|
83
|
+
async def fake_get_attribute(attr_id, index=0, count=1):
|
|
84
|
+
if attr_id == C2Attribute.MLPrefix:
|
|
85
|
+
return [MLPREFIX_RAW]
|
|
86
|
+
if attr_id == C2Attribute.NetworkedThreadDevices:
|
|
87
|
+
return [_entry(suffix, high_bit_age, Model.VorTechMP40wG3QD, "00000000000001")]
|
|
88
|
+
raise AssertionError(f"unexpected attribute: {attr_id}")
|
|
89
|
+
|
|
90
|
+
device.get_attribute = fake_get_attribute
|
|
91
|
+
peers = await device.discover_networked_thread_devices()
|
|
92
|
+
|
|
93
|
+
assert len(peers) == 1
|
|
94
|
+
assert peers[0].age == high_bit_age
|
|
95
|
+
assert peers[0].age > 0
|
|
96
|
+
|
|
97
|
+
|
|
71
98
|
@pytest.mark.asyncio
|
|
72
99
|
async def test_discover_networked_thread_devices_filters_empty_slots():
|
|
73
100
|
device = MobiusDevice(_fake_ble_device())
|
|
@@ -126,14 +153,14 @@ async def test_discover_networked_thread_devices_skips_wrong_length_entries():
|
|
|
126
153
|
if attr_id == C2Attribute.NetworkedThreadDevices:
|
|
127
154
|
return [
|
|
128
155
|
b"\x00" * 10, # too short -- skipped
|
|
129
|
-
_entry(suffix, 5, Model.VorTechMP40wG3QD, "
|
|
156
|
+
_entry(suffix, 5, Model.VorTechMP40wG3QD, "00000000000001"),
|
|
130
157
|
]
|
|
131
158
|
raise AssertionError(f"unexpected attribute: {attr_id}")
|
|
132
159
|
|
|
133
160
|
device.get_attribute = fake_get_attribute
|
|
134
161
|
peers = await device.discover_networked_thread_devices()
|
|
135
162
|
assert len(peers) == 1
|
|
136
|
-
assert peers[0].serial == "
|
|
163
|
+
assert peers[0].serial == "00000000000001"
|
|
137
164
|
|
|
138
165
|
|
|
139
166
|
@pytest.mark.asyncio
|
|
@@ -187,7 +214,7 @@ async def test_discover_mesh_peers_auto_prefers_networked_thread_devices():
|
|
|
187
214
|
if attr_id == C2Attribute.MLPrefix:
|
|
188
215
|
return [MLPREFIX_RAW]
|
|
189
216
|
if attr_id == C2Attribute.NetworkedThreadDevices:
|
|
190
|
-
return [_entry(suffix, 1, Model.VorTechMP40wG3QD, "
|
|
217
|
+
return [_entry(suffix, 1, Model.VorTechMP40wG3QD, "00000000000001")]
|
|
191
218
|
raise AssertionError(
|
|
192
219
|
f"discover_mesh_peers() (the fallback) should not have been "
|
|
193
220
|
f"reached: attribute {attr_id}"
|
|
@@ -196,7 +223,7 @@ async def test_discover_mesh_peers_auto_prefers_networked_thread_devices():
|
|
|
196
223
|
device.get_attribute = fake_get_attribute
|
|
197
224
|
peers = await device.discover_mesh_peers_auto()
|
|
198
225
|
assert len(peers) == 1
|
|
199
|
-
assert peers[0].serial == "
|
|
226
|
+
assert peers[0].serial == "00000000000001"
|
|
200
227
|
assert peers[0].age == 1 # only set by the newer mechanism
|
|
201
228
|
|
|
202
229
|
|
|
@@ -215,7 +242,7 @@ async def test_discover_mesh_peers_auto_falls_back_to_discover_mesh_peers():
|
|
|
215
242
|
if attr_id == C2Attribute.MeshLocalAddresses:
|
|
216
243
|
return [own_address]
|
|
217
244
|
if attr_id == C2Attribute.SerialNumberArray:
|
|
218
|
-
return [b"
|
|
245
|
+
return [b"00000000000001"]
|
|
219
246
|
if attr_id == C2Attribute.DeviceModelArray:
|
|
220
247
|
return [struct.pack("<h", Model.VorTechMP40wG3QD.value)]
|
|
221
248
|
if attr_id == C2Attribute.ShortAddressArray:
|
|
@@ -225,7 +252,7 @@ async def test_discover_mesh_peers_auto_falls_back_to_discover_mesh_peers():
|
|
|
225
252
|
device.get_attribute = fake_get_attribute
|
|
226
253
|
peers = await device.discover_mesh_peers_auto()
|
|
227
254
|
assert len(peers) == 1
|
|
228
|
-
assert peers[0].serial == "
|
|
255
|
+
assert peers[0].serial == "00000000000001"
|
|
229
256
|
assert peers[0].address == build_rloc_address(PREFIX, 0x1234)
|
|
230
257
|
assert peers[0].age is None # not set by the older mechanism
|
|
231
258
|
|
|
@@ -243,22 +270,26 @@ async def test_discover_mesh_peers_auto_returns_empty_if_both_fail():
|
|
|
243
270
|
|
|
244
271
|
|
|
245
272
|
# --------------------------------------------------------------------------
|
|
246
|
-
#
|
|
247
|
-
# lights), including 26 empty slots interleaved with the
|
|
248
|
-
# Confirms this method's parsing against genuine wire
|
|
249
|
-
# synthetic fixtures --
|
|
250
|
-
# and the
|
|
251
|
-
#
|
|
273
|
+
# Shaped after a real hardware capture -- a live 4-device tank (2 VorTech
|
|
274
|
+
# pumps, 2 Radion lights), including 26 empty slots interleaved with the
|
|
275
|
+
# real entries. Confirms this method's parsing against genuine wire
|
|
276
|
+
# structure, not just synthetic fixtures -- the entry layout (suffix,
|
|
277
|
+
# age, model, serial fields) and the age/model values themselves are the
|
|
278
|
+
# real, independently-verified capture; each device's own suffix and
|
|
279
|
+
# serial have been swapped for obviously-fake placeholders of the same
|
|
280
|
+
# byte length (and short_address/address below updated to match those
|
|
281
|
+
# placeholders, since both are deterministically derived from the
|
|
282
|
+
# suffix bytes).
|
|
252
283
|
# --------------------------------------------------------------------------
|
|
253
284
|
|
|
254
285
|
REAL_MLPREFIX_RAW = bytes.fromhex(
|
|
255
|
-
"
|
|
286
|
+
"fdaaaaaaaaaaaaaa" + "00" * 16
|
|
256
287
|
)
|
|
257
288
|
REAL_ENTRIES_RAW = [
|
|
258
|
-
bytes.fromhex("
|
|
259
|
-
bytes.fromhex("
|
|
260
|
-
bytes.fromhex("
|
|
261
|
-
bytes.fromhex("
|
|
289
|
+
bytes.fromhex("000000000000000100000000b30046414b4553455249414c30303032"),
|
|
290
|
+
bytes.fromhex("0000000000000002750100002a003030303030303030303030303031"),
|
|
291
|
+
bytes.fromhex("0000000000000003b73800002a003030303030303030303030303032"),
|
|
292
|
+
bytes.fromhex("00000000000000042a210000b30046414b4553455249414c30303031"),
|
|
262
293
|
] + [bytes.fromhex("00" * 28)] * 26 # 26 empty slots, interleaved on real hardware
|
|
263
294
|
|
|
264
295
|
|
|
@@ -280,23 +311,23 @@ async def test_discover_networked_thread_devices_real_hardware_capture():
|
|
|
280
311
|
|
|
281
312
|
by_serial = {p.serial: p for p in peers}
|
|
282
313
|
|
|
283
|
-
p = by_serial["
|
|
314
|
+
p = by_serial["FAKESERIAL0002"]
|
|
284
315
|
assert p.model == Model.RadionXR15wG6Pro
|
|
285
316
|
assert p.age == 0
|
|
286
|
-
assert p.short_address ==
|
|
287
|
-
assert p.address == bytes.fromhex("
|
|
317
|
+
assert p.short_address == 0x0100
|
|
318
|
+
assert p.address == bytes.fromhex("fdaaaaaaaaaaaaaa0000000000000001")
|
|
288
319
|
|
|
289
|
-
p = by_serial["
|
|
320
|
+
p = by_serial["00000000000001"]
|
|
290
321
|
assert p.model == Model.VorTechMP40wG3QD
|
|
291
322
|
assert p.age == 373
|
|
292
|
-
assert p.short_address ==
|
|
323
|
+
assert p.short_address == 0x0200
|
|
293
324
|
|
|
294
|
-
p = by_serial["
|
|
325
|
+
p = by_serial["00000000000002"]
|
|
295
326
|
assert p.model == Model.VorTechMP40wG3QD
|
|
296
327
|
assert p.age == 14519
|
|
297
|
-
assert p.short_address ==
|
|
328
|
+
assert p.short_address == 0x0300
|
|
298
329
|
|
|
299
|
-
p = by_serial["
|
|
330
|
+
p = by_serial["FAKESERIAL0001"]
|
|
300
331
|
assert p.model == Model.RadionXR15wG6Pro
|
|
301
332
|
assert p.age == 8490
|
|
302
|
-
assert p.short_address ==
|
|
333
|
+
assert p.short_address == 0x0400
|
|
@@ -5,7 +5,7 @@ see documentation/07-pump-schedule.md.
|
|
|
5
5
|
"""
|
|
6
6
|
from mobius import (
|
|
7
7
|
PumpPrimitiveValue, PumpSchedulePoint, get_active_pump_block,
|
|
8
|
-
PumpMode, PumpParam,
|
|
8
|
+
PumpMode, PumpParam, SchedulePoint,
|
|
9
9
|
)
|
|
10
10
|
|
|
11
11
|
|
|
@@ -55,6 +55,21 @@ def test_schedule_point_parses_and_rejects_inactive():
|
|
|
55
55
|
assert PumpSchedulePoint.parse(raw_inactive) is None
|
|
56
56
|
|
|
57
57
|
|
|
58
|
+
def test_has_reads_the_same_flags_field_schedulepoint_does():
|
|
59
|
+
"""A real, confirmed bug this covers: PumpSchedulePoint used to have
|
|
60
|
+
no has() method at all, despite sharing the exact same wire format
|
|
61
|
+
and flag bit meanings as SchedulePoint (light schedules) -- crashed
|
|
62
|
+
mobius.cli's own --dump-schedule for pumps specifically, since its
|
|
63
|
+
_format_flags() helper is shared between both branches."""
|
|
64
|
+
# FLAG_ACTIVE=1 | FLAG_NIGHT=2 = 3
|
|
65
|
+
night_point = PumpSchedulePoint(480, 3, PumpPrimitiveValue(PumpMode.ConstantSpeed, {}))
|
|
66
|
+
assert night_point.has(SchedulePoint.FLAG_NIGHT) is True
|
|
67
|
+
assert night_point.has(SchedulePoint.FLAG_SUNRISE) is False
|
|
68
|
+
|
|
69
|
+
day_point = PumpSchedulePoint(480, 1, PumpPrimitiveValue(PumpMode.ConstantSpeed, {}))
|
|
70
|
+
assert day_point.has(SchedulePoint.FLAG_NIGHT) is False
|
|
71
|
+
|
|
72
|
+
|
|
58
73
|
def _make_pump_point(minute, mode, max_speed):
|
|
59
74
|
return PumpSchedulePoint(minute, 1, PumpPrimitiveValue(mode, {PumpParam.MaxSpeed: max_speed}))
|
|
60
75
|
|
|
@@ -25,7 +25,7 @@ from mobius.frame import (
|
|
|
25
25
|
|
|
26
26
|
|
|
27
27
|
TARGET_ADDRESS = bytes.fromhex("fd11223344556677000000fffe001234")
|
|
28
|
-
PEER = MeshPeer(serial="
|
|
28
|
+
PEER = MeshPeer(serial="FAKESERIAL0001", model_raw=179, model=None,
|
|
29
29
|
short_address=0x1234, address=TARGET_ADDRESS)
|
|
30
30
|
|
|
31
31
|
|
|
@@ -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
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
from mobius.device import _decode_serial
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_decodes_ascii_serial_in_the_real_pump_wire_format():
|
|
5
|
+
# A VorTech MP40QD pump's real 14-byte ASCII serial field format,
|
|
6
|
+
# with an obviously-fake placeholder value.
|
|
7
|
+
assert _decode_serial(bytes.fromhex("3030303030303030303030303032")) == "00000000000002"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def test_decodes_ascii_serial_in_the_real_light_wire_format():
|
|
11
|
+
# A Radion XR15 G6 Pro's real 14-byte ASCII serial field format,
|
|
12
|
+
# with an obviously-fake placeholder value.
|
|
13
|
+
assert _decode_serial(bytes.fromhex("46414b4553455249414c30303031")) == "FAKESERIAL0001"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_strips_trailing_nulls():
|
|
17
|
+
assert _decode_serial(b"ABC123\x00\x00\x00") == "ABC123"
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def test_falls_back_to_hex_for_non_ascii():
|
|
21
|
+
raw = bytes([0, 1, 2, 3])
|
|
22
|
+
assert _decode_serial(raw) == raw.hex()
|
|
@@ -1,20 +0,0 @@
|
|
|
1
|
-
from mobius.device import _decode_serial
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
def test_decodes_real_pump_serial_as_ascii():
|
|
5
|
-
# Real captured serial from a VorTech MP40QD pump.
|
|
6
|
-
assert _decode_serial(bytes.fromhex("3736343935323231303539303139")) == "76495221059019"
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
def test_decodes_real_light_serial_as_ascii():
|
|
10
|
-
# Real captured serial from a Radion XR15 G6 Pro.
|
|
11
|
-
assert _decode_serial(bytes.fromhex("3756345a30304631343352424544")) == "7V4Z00F143RBED"
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def test_strips_trailing_nulls():
|
|
15
|
-
assert _decode_serial(b"ABC123\x00\x00\x00") == "ABC123"
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
def test_falls_back_to_hex_for_non_ascii():
|
|
19
|
-
raw = bytes([0, 1, 2, 3])
|
|
20
|
-
assert _decode_serial(raw) == raw.hex()
|
|
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.1 → 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
|