python-mobius 0.4.1__tar.gz → 0.4.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.
- {python_mobius-0.4.1 → python_mobius-0.4.2}/CHANGELOG.md +16 -2
- {python_mobius-0.4.1 → python_mobius-0.4.2}/PKG-INFO +2 -2
- {python_mobius-0.4.1 → python_mobius-0.4.2}/documentation/09-thread-coap-relay.md +6 -3
- {python_mobius-0.4.1 → python_mobius-0.4.2}/pyproject.toml +1 -1
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/__init__.py +1 -1
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/device.py +12 -4
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/device_status.py +15 -6
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/schedule.py +10 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_device_summary.py +1 -1
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_light_schedule.py +10 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_manufacturer.py +12 -9
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_mesh_discovery.py +17 -17
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_mesh_discovery_direct_connect.py +15 -15
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_networked_thread_devices.py +62 -31
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_pump_schedule.py +16 -1
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_relay.py +1 -1
- python_mobius-0.4.2/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.2}/.forgejo/scripts/extract_changelog_section.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/.forgejo/workflows/release.yml +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/.gitignore +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/LICENSE +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/README.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/documentation/00-overview.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/documentation/01-ble-transport.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/documentation/02-framing-and-crc.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/documentation/03-attributes-and-opcodes.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/documentation/04-device-identity.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/documentation/05-scenes.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/documentation/06-light-schedule.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/documentation/07-pump-schedule.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/documentation/08-manufacturer-data.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/documentation/10-known-gaps-and-open-questions.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/documentation/11-device-status-attributes.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/documentation/12-device-identity-and-address-stability.md +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/cli.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/coap.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/constants.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/crc.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/discovery.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/frame.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/manufacturer.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/mesh_address.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/modifiers.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/power.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/pump_status.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/src/mobius/relay.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_coap.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_coap_indication_handling.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_connection_cleanup.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_device_status.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_device_time.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_firmware_versions.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_frame.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_frame_sniffer.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_hyperdrive_device.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_insolation_detection.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_light_intensity_branching.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_manufacturer_lookup.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_mesh_address.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_modifiers.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_power.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_pump_status.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_schedule_intensity_scalar.py +0 -0
- {python_mobius-0.4.1 → python_mobius-0.4.2}/tests/test_serial_identity.py +0 -0
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.4.2
|
|
4
|
+
|
|
5
|
+
- Fixed `MeshPeer.age` being parsed as a signed number instead of
|
|
6
|
+
unsigned -- could have shown as negative for a peer that had been
|
|
7
|
+
unreachable a very long time. Also confirmed what this value
|
|
8
|
+
actually means: milliseconds since that peer was last heard from on
|
|
9
|
+
the mesh, not since it was first found. It resets whenever the mesh
|
|
10
|
+
hears from that device again, so seeing it go down between two
|
|
11
|
+
checks is expected, not a bug.
|
|
12
|
+
- Fixed `mobius-scan --dump-schedule` crashing for pumps specifically
|
|
13
|
+
(`'PumpSchedulePoint' object has no attribute 'has'`) -- that class
|
|
14
|
+
was missing a method `SchedulePoint` (the light-schedule equivalent)
|
|
15
|
+
already had, despite sharing the exact same flag bits.
|
|
16
|
+
|
|
3
17
|
## 0.4.1
|
|
4
18
|
|
|
5
19
|
- Fixed `discover_tank()` sometimes reporting the connected device
|
|
@@ -634,8 +648,8 @@ branch has been merged into main as part of this release.
|
|
|
634
648
|
Added while building a Home Assistant integration on top of this library,
|
|
635
649
|
which needs a manufacturer string for its device registry.
|
|
636
650
|
- `get_device_info()`'s `"serial"` field is now decoded as ASCII text
|
|
637
|
-
instead of hex-encoded raw bytes (e.g. `"
|
|
638
|
-
`"
|
|
651
|
+
instead of hex-encoded raw bytes (e.g. `"FAKESERIAL0001"` instead of
|
|
652
|
+
`"46414b4553455249414c30303031"`). Confirmed printable ASCII on every
|
|
639
653
|
device tested, via cross-validation against the same serial parsed from
|
|
640
654
|
BLE advertisement manufacturer data. Falls back to hex for any
|
|
641
655
|
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.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
|
|
@@ -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.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" }
|
|
@@ -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
|
|
@@ -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
|
|
|
@@ -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.2}/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
|