python-mobius 0.4.0__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.0 → python_mobius-0.4.2}/.forgejo/workflows/release.yml +25 -20
- {python_mobius-0.4.0 → python_mobius-0.4.2}/CHANGELOG.md +21 -2
- {python_mobius-0.4.0 → python_mobius-0.4.2}/PKG-INFO +2 -2
- {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/09-thread-coap-relay.md +6 -3
- {python_mobius-0.4.0 → python_mobius-0.4.2}/pyproject.toml +1 -1
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/__init__.py +1 -1
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/device.py +38 -10
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/device_status.py +15 -6
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/discovery.py +31 -7
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/schedule.py +10 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_device_summary.py +1 -1
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_light_schedule.py +10 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_manufacturer.py +12 -9
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_mesh_discovery.py +85 -14
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_mesh_discovery_direct_connect.py +15 -15
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_networked_thread_devices.py +62 -31
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_pump_schedule.py +16 -1
- {python_mobius-0.4.0 → 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.0/tests/test_serial_decoding.py +0 -20
- {python_mobius-0.4.0 → python_mobius-0.4.2}/.forgejo/scripts/extract_changelog_section.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/.gitignore +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/LICENSE +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/README.md +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/00-overview.md +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/01-ble-transport.md +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/02-framing-and-crc.md +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/03-attributes-and-opcodes.md +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/04-device-identity.md +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/05-scenes.md +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/06-light-schedule.md +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/07-pump-schedule.md +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/08-manufacturer-data.md +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/10-known-gaps-and-open-questions.md +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/11-device-status-attributes.md +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/12-device-identity-and-address-stability.md +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/cli.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/coap.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/constants.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/crc.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/frame.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/manufacturer.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/mesh_address.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/modifiers.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/power.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/pump_status.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/relay.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_coap.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_coap_indication_handling.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_connection_cleanup.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_device_status.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_device_time.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_firmware_versions.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_frame.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_frame_sniffer.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_hyperdrive_device.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_insolation_detection.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_light_intensity_branching.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_manufacturer_lookup.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_mesh_address.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_modifiers.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_power.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_pump_status.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_schedule_intensity_scalar.py +0 -0
- {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_serial_identity.py +0 -0
|
@@ -88,25 +88,30 @@ jobs:
|
|
|
88
88
|
if: github.event_name == 'push'
|
|
89
89
|
run: python3 .forgejo/scripts/extract_changelog_section.py "${{ github.ref_name }}"
|
|
90
90
|
|
|
91
|
-
#
|
|
92
|
-
#
|
|
93
|
-
#
|
|
94
|
-
#
|
|
95
|
-
#
|
|
96
|
-
#
|
|
97
|
-
#
|
|
98
|
-
#
|
|
99
|
-
#
|
|
100
|
-
#
|
|
101
|
-
#
|
|
102
|
-
#
|
|
103
|
-
#
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
#
|
|
109
|
-
#
|
|
91
|
+
# Confirmed via direct comparison against ha-mobius's own release
|
|
92
|
+
# workflow (which has never hit this failure) exactly why an
|
|
93
|
+
# earlier version of this step kept failing on a real run ("tag
|
|
94
|
+
# vX.Y.Z exists" / "curl: (26) Failed to open/read local data from
|
|
95
|
+
# file/application") even after release-dir was set explicitly:
|
|
96
|
+
# the YAML here and in ha-mobius's version are structurally
|
|
97
|
+
# identical (same action, same inputs), but ha-mobius's
|
|
98
|
+
# release-dir always has at least one real file in it (a zip it
|
|
99
|
+
# builds beforehand for manual installers), while this repo's own
|
|
100
|
+
# release-dir was a directory that existed but was ALWAYS
|
|
101
|
+
# completely empty (deliberately, since PyPI is meant to be the
|
|
102
|
+
# source of truth here, not a Forgejo release asset). That's
|
|
103
|
+
# consistent with the action's own upload logic looping over
|
|
104
|
+
# release-dir's contents with an unquoted/unguarded shell glob:
|
|
105
|
+
# with zero matching files and no nullglob set, such a glob
|
|
106
|
+
# doesn't expand to nothing, it stays as the literal, unexpanded
|
|
107
|
+
# string -- and curl trying to read a file literally named
|
|
108
|
+
# "dist/release/*" produces exactly this error. Rather than an
|
|
109
|
+
# artificially empty directory, this points release-dir at dist/
|
|
110
|
+
# itself, which already has the actual built wheel/sdist in it
|
|
111
|
+
# from the Build package step above and is therefore never empty
|
|
112
|
+
# -- also means those get attached to the Forgejo release as a
|
|
113
|
+
# side effect, which is a reasonable thing to have anyway, not
|
|
114
|
+
# just a workaround.
|
|
110
115
|
- name: Create Forgejo release
|
|
111
116
|
if: github.event_name == 'push'
|
|
112
117
|
uses: https://code.forgejo.org/actions/forgejo-release@v2
|
|
@@ -116,5 +121,5 @@ jobs:
|
|
|
116
121
|
repo: r3pek/python-mobius
|
|
117
122
|
token: ${{ secrets.GITHUB_TOKEN }}
|
|
118
123
|
tag: ${{ github.ref_name }}
|
|
119
|
-
release-dir: dist
|
|
124
|
+
release-dir: dist
|
|
120
125
|
release-notes-file: release_notes.md
|
|
@@ -1,5 +1,24 @@
|
|
|
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
|
+
|
|
17
|
+
## 0.4.1
|
|
18
|
+
|
|
19
|
+
- Fixed `discover_tank()` sometimes reporting the connected device
|
|
20
|
+
twice in a tank's peer list.
|
|
21
|
+
|
|
3
22
|
## 0.4.0
|
|
4
23
|
|
|
5
24
|
- **`mobius-scan`'s default now also connects to ONE device and dumps
|
|
@@ -629,8 +648,8 @@ branch has been merged into main as part of this release.
|
|
|
629
648
|
Added while building a Home Assistant integration on top of this library,
|
|
630
649
|
which needs a manufacturer string for its device registry.
|
|
631
650
|
- `get_device_info()`'s `"serial"` field is now decoded as ASCII text
|
|
632
|
-
instead of hex-encoded raw bytes (e.g. `"
|
|
633
|
-
`"
|
|
651
|
+
instead of hex-encoded raw bytes (e.g. `"FAKESERIAL0001"` instead of
|
|
652
|
+
`"46414b4553455249414c30303031"`). Confirmed printable ASCII on every
|
|
634
653
|
device tested, via cross-validation against the same serial parsed from
|
|
635
654
|
BLE advertisement manufacturer data. Falls back to hex for any
|
|
636
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" }
|
|
@@ -709,12 +709,22 @@ class MobiusDevice:
|
|
|
709
709
|
ShortAddressArray/SerialNumberArray/DeviceModelArray
|
|
710
710
|
(3700/3701/3702, the same "get all elements" pattern used
|
|
711
711
|
elsewhere in this library) from THIS connected device, acting as
|
|
712
|
-
a Thread mesh gateway, and returns
|
|
713
|
-
|
|
714
|
-
|
|
712
|
+
a Thread mesh gateway, and returns what it reports about devices
|
|
713
|
+
over the mesh -- not devices we necessarily have (or even can
|
|
714
|
+
have) a direct BLE connection to. See documentation/
|
|
715
715
|
09-thread-coap-relay.md for the full protocol trace this is
|
|
716
716
|
based on.
|
|
717
717
|
|
|
718
|
+
Whether this includes the connected device's own entry
|
|
719
|
+
alongside others isn't confirmed either way -- unlike
|
|
720
|
+
discover_networked_thread_devices() (whose sibling docstring
|
|
721
|
+
this once matched, until a real capture disproved it there),
|
|
722
|
+
real hardware evidence specific to THIS older mechanism was
|
|
723
|
+
never gathered. Callers that also separately add the connected
|
|
724
|
+
device's own entry (e.g. mobius.discovery.discover_tank()) treat
|
|
725
|
+
this as a real possibility and deduplicate by serial regardless,
|
|
726
|
+
rather than assuming either way.
|
|
727
|
+
|
|
718
728
|
**Confirmed via real hardware testing to be Cowboy-hub-specific.**
|
|
719
729
|
These three arrays were originally found being fetched as part of
|
|
720
730
|
the app's own network-setup logic for a dedicated Cowboy hub device,
|
|
@@ -789,9 +799,19 @@ class MobiusDevice:
|
|
|
789
799
|
"""
|
|
790
800
|
Fetches MLPrefix (2128) plus NetworkedThreadDevices (1001, the
|
|
791
801
|
same "get all elements" pattern used elsewhere in this library)
|
|
792
|
-
from THIS connected device, and returns every
|
|
793
|
-
|
|
794
|
-
|
|
802
|
+
from THIS connected device, and returns every device it knows
|
|
803
|
+
about over the Thread mesh -- not devices we necessarily have
|
|
804
|
+
(or even can have) a direct BLE connection to.
|
|
805
|
+
|
|
806
|
+
**Includes the connected device's own entry, not just others.**
|
|
807
|
+
An earlier version of this docstring claimed "every OTHER
|
|
808
|
+
device," an untested assumption rather than a confirmed one --
|
|
809
|
+
disproven by a real 4-device tank capture where a single
|
|
810
|
+
connected device's own NetworkedThreadDevices read returned all
|
|
811
|
+
4 real devices, necessarily including itself. Callers building
|
|
812
|
+
a peer list that also separately adds the connected device's own
|
|
813
|
+
entry (e.g. mobius.discovery.discover_tank()) need to
|
|
814
|
+
deduplicate by serial, or that device ends up listed twice.
|
|
795
815
|
|
|
796
816
|
A second, better mesh-peer-discovery mechanism than
|
|
797
817
|
discover_mesh_peers() -- confirmed via reverse engineering the
|
|
@@ -823,9 +843,11 @@ class MobiusDevice:
|
|
|
823
843
|
build_rloc_address(), which is for the different case of only
|
|
824
844
|
having a 2-byte short address to encode into the prefix's last 2
|
|
825
845
|
bytes -- here the full 8-byte suffix is already present in the
|
|
826
|
-
entry, so it's used as-is); bytes 8-11 are a 4-byte
|
|
827
|
-
|
|
828
|
-
|
|
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
|
|
829
851
|
ID; bytes 14-27 are the 14-byte ASCII serial number, matching the
|
|
830
852
|
SerialNumberArray format used elsewhere in this library. All
|
|
831
853
|
multi-byte numeric fields are read little-endian -- confirmed
|
|
@@ -868,7 +890,13 @@ class MobiusDevice:
|
|
|
868
890
|
if len(entry) != 28:
|
|
869
891
|
continue
|
|
870
892
|
address = prefix + entry[0:8]
|
|
871
|
-
|
|
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]
|
|
872
900
|
model_raw = struct.unpack("<h", entry[12:14])[0]
|
|
873
901
|
try:
|
|
874
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
|
|
@@ -193,12 +193,28 @@ async def discover_tank(mdevice) -> "Tank":
|
|
|
193
193
|
(not just serial/model, the way advertisement-only grouping like
|
|
194
194
|
group_by_pan_id() can offer).
|
|
195
195
|
|
|
196
|
-
Calls mdevice.discover_mesh_peers_auto()
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
196
|
+
Calls mdevice.discover_mesh_peers_auto() plus
|
|
197
|
+
get_own_mesh_address()/get_device_info() to build the connected
|
|
198
|
+
device's own entry directly. These are deliberately DEDUPLICATED by
|
|
199
|
+
serial before returning, keeping the directly-built own entry over
|
|
200
|
+
whatever discover_mesh_peers_auto() reported for the same serial --
|
|
201
|
+
confirmed via a real 4-device tank capture that
|
|
202
|
+
discover_networked_thread_devices() (the mechanism
|
|
203
|
+
discover_mesh_peers_auto() tries first) is self-INCLUSIVE, not
|
|
204
|
+
self-exclusive as an earlier version of this docstring claimed
|
|
205
|
+
(based on an untested assumption, not a confirmed one): all 4 real
|
|
206
|
+
devices came back from a single connected device's own
|
|
207
|
+
NetworkedThreadDevices read, necessarily including whichever one was
|
|
208
|
+
actually connected to. Without deduplication, that connected
|
|
209
|
+
device's own peer would appear twice in the result -- once from this
|
|
210
|
+
function's own direct build, once from discover_mesh_peers_auto()'s
|
|
211
|
+
own report of it as "just another peer" on its own network. Not
|
|
212
|
+
confirmed whether the OLDER discover_mesh_peers() mechanism
|
|
213
|
+
(discover_mesh_peers_auto()'s own fallback) shares this same
|
|
214
|
+
self-inclusive behavior or not -- the deduplication here is
|
|
215
|
+
unconditional specifically so it's correct either way, rather than
|
|
216
|
+
depending on getting that confirmation for every code path that
|
|
217
|
+
could produce peers.
|
|
202
218
|
|
|
203
219
|
tank.prefix is None if this device didn't report a valid mesh
|
|
204
220
|
address at all (unsupported, or not currently part of an active
|
|
@@ -240,5 +256,13 @@ async def discover_tank(mdevice) -> "Tank":
|
|
|
240
256
|
other_peers = await mdevice.discover_mesh_peers_auto()
|
|
241
257
|
prefix = mesh_local_prefix_from_own_address(own_address)
|
|
242
258
|
|
|
243
|
-
|
|
259
|
+
# own_peer always wins over whatever discover_mesh_peers_auto()
|
|
260
|
+
# separately reported for the same serial -- see this function's
|
|
261
|
+
# own docstring for why that duplicate is a real, confirmed
|
|
262
|
+
# possibility, not just a defensive guard against a hypothetical.
|
|
263
|
+
deduplicated = [own_peer] + [
|
|
264
|
+
peer for peer in other_peers if peer.serial != own_peer.serial
|
|
265
|
+
]
|
|
266
|
+
|
|
267
|
+
return Tank(prefix=prefix, peers=deduplicated)
|
|
244
268
|
|
|
@@ -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
|
|
|
@@ -13,7 +13,7 @@ from bleak.backends.device import BLEDevice
|
|
|
13
13
|
|
|
14
14
|
from mobius import C2Attribute, Model, MeshPeer
|
|
15
15
|
from mobius.device import MobiusDevice
|
|
16
|
-
from mobius.mesh_address import build_rloc_address
|
|
16
|
+
from mobius.mesh_address import build_rloc_address, extract_short_address
|
|
17
17
|
|
|
18
18
|
|
|
19
19
|
def _fake_ble_device():
|
|
@@ -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,12 +289,83 @@ 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
|
|
296
296
|
|
|
297
297
|
|
|
298
|
+
@pytest.mark.asyncio
|
|
299
|
+
async def test_discover_tank_deduplicates_self_from_discover_mesh_peers_auto():
|
|
300
|
+
"""
|
|
301
|
+
The actual real-hardware bug this guards against: a real 4-device
|
|
302
|
+
tank capture showed discover_networked_thread_devices() (the
|
|
303
|
+
mechanism discover_mesh_peers_auto() tries first) returning ALL 4
|
|
304
|
+
real devices from a single connected device's own read, including
|
|
305
|
+
itself -- disproving an earlier assumption that it only ever
|
|
306
|
+
reports OTHER devices (see device.py's own docstring for the full
|
|
307
|
+
correction). Without deduplication, discover_tank() would list the
|
|
308
|
+
connected device twice: once from its own direct
|
|
309
|
+
get_own_mesh_address()/get_device_info() build, once again from
|
|
310
|
+
whatever discover_mesh_peers_auto() separately reported for that
|
|
311
|
+
same serial. Confirms the fix: the connected device appears exactly
|
|
312
|
+
once, its own directly-built entry wins over the (here, identical)
|
|
313
|
+
one discover_mesh_peers_auto() reported for the same serial, and
|
|
314
|
+
genuinely different peers are unaffected.
|
|
315
|
+
"""
|
|
316
|
+
device = MobiusDevice(_fake_ble_device())
|
|
317
|
+
own_serial = "00000000000002"
|
|
318
|
+
own_address = PREFIX + bytes.fromhex("000000fffe005786")
|
|
319
|
+
|
|
320
|
+
# What discover_mesh_peers_auto() reports for the connected device's
|
|
321
|
+
# OWN serial -- same identity, but constructed independently (a
|
|
322
|
+
# DIFFERENT MeshPeer object, not literally the same instance the
|
|
323
|
+
# real own_peer build produces), matching how two separate discovery
|
|
324
|
+
# paths reporting the same physical device would actually look.
|
|
325
|
+
self_reported_by_auto = MeshPeer(
|
|
326
|
+
serial=own_serial, model_raw=Model.VorTechMP40wG3QD.value,
|
|
327
|
+
model=Model.VorTechMP40wG3QD, short_address=extract_short_address(own_address),
|
|
328
|
+
address=own_address, age=14519,
|
|
329
|
+
)
|
|
330
|
+
genuinely_other_peer = MeshPeer(
|
|
331
|
+
serial="00000000000001", model_raw=Model.VorTechMP40wG3QD.value,
|
|
332
|
+
model=Model.VorTechMP40wG3QD, short_address=0x0200,
|
|
333
|
+
address=PREFIX + bytes.fromhex("000000fffe009a0b"), age=373,
|
|
334
|
+
)
|
|
335
|
+
|
|
336
|
+
async def fake_get_attribute(attr_id, index=0, count=1):
|
|
337
|
+
if attr_id == C2Attribute.MeshLocalAddresses:
|
|
338
|
+
return [own_address]
|
|
339
|
+
info_attrs = _device_info_attrs(Model.VorTechMP40wG3QD, own_serial)
|
|
340
|
+
if attr_id in info_attrs:
|
|
341
|
+
return info_attrs[attr_id]
|
|
342
|
+
raise Exception("FsciStatus.UnsupportedAttribute")
|
|
343
|
+
|
|
344
|
+
device.get_attribute = fake_get_attribute
|
|
345
|
+
device.discover_mesh_peers_auto = AsyncMock(
|
|
346
|
+
return_value=[self_reported_by_auto, genuinely_other_peer]
|
|
347
|
+
)
|
|
348
|
+
|
|
349
|
+
tank = await discover_tank(device)
|
|
350
|
+
|
|
351
|
+
# The actual point: 2 real devices total, not 3 -- the connected
|
|
352
|
+
# device isn't double-counted.
|
|
353
|
+
assert len(tank.peers) == 2
|
|
354
|
+
serials = [p.serial for p in tank.peers]
|
|
355
|
+
assert serials.count(own_serial) == 1
|
|
356
|
+
assert own_serial in serials
|
|
357
|
+
assert "00000000000001" in serials
|
|
358
|
+
# The connected device's own directly-built entry wins -- confirmed
|
|
359
|
+
# by checking it's the exact instance discover_tank() constructs
|
|
360
|
+
# itself (has no .age set, unlike self_reported_by_auto which does),
|
|
361
|
+
# not the one discover_mesh_peers_auto() separately reported.
|
|
362
|
+
own_in_result = next(p for p in tank.peers if p.serial == own_serial)
|
|
363
|
+
assert own_in_result is not self_reported_by_auto
|
|
364
|
+
assert own_in_result.age is None
|
|
365
|
+
# The genuinely different peer is untouched.
|
|
366
|
+
assert genuinely_other_peer in tank.peers
|
|
367
|
+
|
|
368
|
+
|
|
298
369
|
@pytest.mark.asyncio
|
|
299
370
|
async def test_discover_tank_no_own_address_returns_empty_tank():
|
|
300
371
|
"""No own address means no valid connection to this Thread network --
|
|
@@ -328,7 +399,7 @@ async def test_discover_tank_single_device_no_other_peers():
|
|
|
328
399
|
async def fake_get_attribute(attr_id, index=0, count=1):
|
|
329
400
|
if attr_id == C2Attribute.MeshLocalAddresses:
|
|
330
401
|
return [own_address]
|
|
331
|
-
info_attrs = _device_info_attrs(Model.VorTechMP40wG3QD, "
|
|
402
|
+
info_attrs = _device_info_attrs(Model.VorTechMP40wG3QD, "00000000000001")
|
|
332
403
|
if attr_id in info_attrs:
|
|
333
404
|
return info_attrs[attr_id]
|
|
334
405
|
raise Exception("FsciStatus.UnsupportedAttribute")
|
|
@@ -339,4 +410,4 @@ async def test_discover_tank_single_device_no_other_peers():
|
|
|
339
410
|
tank = await discover_tank(device)
|
|
340
411
|
assert tank.prefix == PREFIX
|
|
341
412
|
assert len(tank.peers) == 1
|
|
342
|
-
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
|
{python_mobius-0.4.0 → 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
|