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.
Files changed (65) hide show
  1. {python_mobius-0.4.0 → python_mobius-0.4.2}/.forgejo/workflows/release.yml +25 -20
  2. {python_mobius-0.4.0 → python_mobius-0.4.2}/CHANGELOG.md +21 -2
  3. {python_mobius-0.4.0 → python_mobius-0.4.2}/PKG-INFO +2 -2
  4. {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/09-thread-coap-relay.md +6 -3
  5. {python_mobius-0.4.0 → python_mobius-0.4.2}/pyproject.toml +1 -1
  6. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/__init__.py +1 -1
  7. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/device.py +38 -10
  8. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/device_status.py +15 -6
  9. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/discovery.py +31 -7
  10. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/schedule.py +10 -0
  11. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_device_summary.py +1 -1
  12. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_light_schedule.py +10 -0
  13. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_manufacturer.py +12 -9
  14. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_mesh_discovery.py +85 -14
  15. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_mesh_discovery_direct_connect.py +15 -15
  16. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_networked_thread_devices.py +62 -31
  17. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_pump_schedule.py +16 -1
  18. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_relay.py +1 -1
  19. python_mobius-0.4.2/tests/test_serial_decoding.py +22 -0
  20. python_mobius-0.4.0/tests/test_serial_decoding.py +0 -20
  21. {python_mobius-0.4.0 → python_mobius-0.4.2}/.forgejo/scripts/extract_changelog_section.py +0 -0
  22. {python_mobius-0.4.0 → python_mobius-0.4.2}/.gitignore +0 -0
  23. {python_mobius-0.4.0 → python_mobius-0.4.2}/LICENSE +0 -0
  24. {python_mobius-0.4.0 → python_mobius-0.4.2}/README.md +0 -0
  25. {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/00-overview.md +0 -0
  26. {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/01-ble-transport.md +0 -0
  27. {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/02-framing-and-crc.md +0 -0
  28. {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/03-attributes-and-opcodes.md +0 -0
  29. {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/04-device-identity.md +0 -0
  30. {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/05-scenes.md +0 -0
  31. {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/06-light-schedule.md +0 -0
  32. {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/07-pump-schedule.md +0 -0
  33. {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/08-manufacturer-data.md +0 -0
  34. {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/10-known-gaps-and-open-questions.md +0 -0
  35. {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/11-device-status-attributes.md +0 -0
  36. {python_mobius-0.4.0 → python_mobius-0.4.2}/documentation/12-device-identity-and-address-stability.md +0 -0
  37. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/cli.py +0 -0
  38. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/coap.py +0 -0
  39. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/constants.py +0 -0
  40. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/crc.py +0 -0
  41. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/frame.py +0 -0
  42. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/manufacturer.py +0 -0
  43. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/mesh_address.py +0 -0
  44. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/modifiers.py +0 -0
  45. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/power.py +0 -0
  46. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/pump_status.py +0 -0
  47. {python_mobius-0.4.0 → python_mobius-0.4.2}/src/mobius/relay.py +0 -0
  48. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_coap.py +0 -0
  49. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_coap_indication_handling.py +0 -0
  50. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_connection_cleanup.py +0 -0
  51. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_device_status.py +0 -0
  52. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_device_time.py +0 -0
  53. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_firmware_versions.py +0 -0
  54. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_frame.py +0 -0
  55. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_frame_sniffer.py +0 -0
  56. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_hyperdrive_device.py +0 -0
  57. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_insolation_detection.py +0 -0
  58. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_light_intensity_branching.py +0 -0
  59. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_manufacturer_lookup.py +0 -0
  60. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_mesh_address.py +0 -0
  61. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_modifiers.py +0 -0
  62. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_power.py +0 -0
  63. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_pump_status.py +0 -0
  64. {python_mobius-0.4.0 → python_mobius-0.4.2}/tests/test_schedule_intensity_scalar.py +0 -0
  65. {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
- # Both of the action's own README examples always set release-dir
92
- # explicitly, even though it's documented as optional with a
93
- # default -- confirmed via a real failure on this exact step
94
- # ("tag v0.3.1 exists" / "curl: (26) Failed to open/read local
95
- # data from file/application") that omitting it entirely doesn't
96
- # work, the same bug already found and fixed the same way for
97
- # ha-mobius's own release workflow. dist/ has the actual built
98
- # wheel/sdist in it (from the Build package step above), but
99
- # those are deliberately NOT what goes here -- PyPI stays the
100
- # source of truth for the built artifacts, this step only creates
101
- # the release entry with the extracted changelog section as its
102
- # notes -- hence a separate, empty dist/release/ rather than
103
- # pointing release-dir at dist/ itself.
104
- - name: Ensure release-dir exists (empty -- no files attached)
105
- if: github.event_name == 'push'
106
- run: mkdir -p dist/release
107
-
108
- # Same event-gating reason as the two steps above -- nothing to
109
- # release against on a manual, non-tag run.
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/release
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. `"7V4Z00F143RBED"` instead of
633
- `"3756345a30304631343352424544"`). Confirmed printable ASCII on every
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.4
1
+ Metadata-Version: 2.5
2
2
  Name: python-mobius
3
- Version: 0.4.0
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 (present in the wire
177
- format, but its exact meaning isn't confirmed against real hardware --
178
- see `MeshPeer`'s own docstring), a 2-byte model ID, and a 14-byte ASCII
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.0"
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" }
@@ -65,7 +65,7 @@ from .discovery import (
65
65
  discover_tank,
66
66
  )
67
67
 
68
- __version__ = "0.4.0"
68
+ __version__ = "0.4.2"
69
69
 
70
70
  __all__ = [
71
71
  "__version__",
@@ -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 every OTHER device it knows
713
- about over the mesh -- not devices we necessarily have (or even
714
- can have) a direct BLE connection to. See documentation/
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 OTHER device it
793
- knows about over the Thread mesh -- not devices we necessarily
794
- have (or even can have) a direct BLE connection to.
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 "age" value
827
- (see MeshPeer's own docstring for the important caveat that its
828
- exact meaning isn't confirmed); bytes 12-13 are the 2-byte model
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
- age = struct.unpack("<i", entry[8:12])[0]
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 present as a raw 4-byte field in the wire format, but its
134
- exact unit/meaning (freshness of the gateway's own knowledge of this
135
- peer, most likely, given the name -- but not confirmed against real
136
- hardware) is not confirmed; treat it as an opaque, larger-is-probably-
137
- less-fresh value rather than a specific unit of time unless verified.
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 # see docstring -- only set by discover_networked_thread_devices()
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() (which only ever reports
197
- OTHER devices) plus get_own_mesh_address()/get_device_info() (to
198
- include the connected device itself in the result -- otherwise a
199
- single-device tank would come back with zero peers, which would look
200
- identical to "this device supports none of the discovery mechanisms"
201
- rather than "this device IS the whole tank").
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
- return Tank(prefix=prefix, peers=[own_peer] + other_peers)
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"7V4Z00F143RBED"],
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 real advertisement bytes captured
3
- from four devices (two VorTech pumps, two Radion XR15 lights) all sharing
4
- one tank -- see documentation/08-manufacturer-data.md.
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": "2a0001000000000f3d3736343935323231303539303139",
10
- "pump_right": "2a0001000000000f3d3736353137373331393532303431",
11
- "light_1": "b30001000000000f3d3756345a30304631343352424544",
12
- "light_2": "b30001000000000f3d3756345a30304631343952424633",
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 == "76495221059019"
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 == "7V4Z00F143RBED"
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("76517731952041"), _serial_bytes("7V4Z00F143RBED")]
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 == "76517731952041"
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 == "7V4Z00F143RBED"
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("76517731952041"),
84
+ _serial_bytes("00000000000001"),
85
85
  _serial_bytes("00000000000000"), # cleared slot
86
- _serial_bytes("7V4Z00F143RBED"),
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} == {"76517731952041", "7V4Z00F143RBED"}
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("76517731952041")]
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("76517731952041")]
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="76517731952041", model_raw=Model.VorTechMP40wG3QD.value,
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, "7V4Z00F149RBF3")
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 == "7V4Z00F149RBF3"
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, "76517731952041")
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 == "76517731952041"
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("76517731952041", Model.VorTechMP40wG3QD)),
40
- (_ble("BB:BB:BB:BB:BB:BB"), _info("7V4Z00F143RBED", Model.RadionXR15wG6Pro)),
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
- "76517731952041": build_rloc_address(PREFIX, 0x1234),
45
- "7V4Z00F143RBED": build_rloc_address(PREFIX, 0x5678),
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["76517731952041"].address == addresses["76517731952041"]
71
- assert by_serial["76517731952041"].model == Model.VorTechMP40wG3QD
72
- assert by_serial["7V4Z00F143RBED"].address == addresses["7V4Z00F143RBED"]
73
- assert by_serial["7V4Z00F143RBED"].model == Model.RadionXR15wG6Pro
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("7V4Z00F143RBED", Model.RadionXR15wG6Pro)),
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 == "7V4Z00F143RBED"
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("76517731952041", Model.VorTechMP40wG3QD)),
105
- (_ble("BB:BB:BB:BB:BB:BB"), _info("7V4Z00F143RBED", Model.RadionXR15wG6Pro)),
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 == "7V4Z00F143RBED"
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("76517731952041", Model.VorTechMP40wG3QD))]
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("76517731952041", Model.VorTechMP40wG3QD))]
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("<i", age) + struct.pack("<h", model.value) + serial_bytes
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, "76517731952041"),
49
- _entry(suffix_b, 7, Model.RadionXR15wG6Pro, "7V4Z00F143RBED"),
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 == "76517731952041"
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 == "7V4Z00F143RBED"
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, "76517731952041"),
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 == "76517731952041"
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, "76517731952041")]
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 == "76517731952041"
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"76517731952041"]
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 == "76517731952041"
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
- # Real hardware capture -- a live 4-device tank (2 VorTech pumps, 2 Radion
247
- # lights), including 26 empty slots interleaved with the real entries.
248
- # Confirms this method's parsing against genuine wire data, not just
249
- # synthetic fixtures -- every field below (suffix, age, model, serial,
250
- # and the derived short_address/address) was independently verified by
251
- # hand against this exact capture before this test was written.
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
- "fd1c5ec780e35c01" + "00" * 16
286
+ "fdaaaaaaaaaaaaaa" + "00" * 16
256
287
  )
257
288
  REAL_ENTRIES_RAW = [
258
- bytes.fromhex("89766dcd51eef69000000000b3003756345a30304631343952424633"),
259
- bytes.fromhex("14f417a6cf370b9a750100002a003736353137373331393532303431"),
260
- bytes.fromhex("bd0d4f547f328657b73800002a003736343935323231303539303139"),
261
- bytes.fromhex("7c22c78c0ac88b332a210000b3003756345a30304631343352424544"),
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["7V4Z00F149RBF3"]
314
+ p = by_serial["FAKESERIAL0002"]
284
315
  assert p.model == Model.RadionXR15wG6Pro
285
316
  assert p.age == 0
286
- assert p.short_address == 0x90F6
287
- assert p.address == bytes.fromhex("fd1c5ec780e35c0189766dcd51eef690")
317
+ assert p.short_address == 0x0100
318
+ assert p.address == bytes.fromhex("fdaaaaaaaaaaaaaa0000000000000001")
288
319
 
289
- p = by_serial["76517731952041"]
320
+ p = by_serial["00000000000001"]
290
321
  assert p.model == Model.VorTechMP40wG3QD
291
322
  assert p.age == 373
292
- assert p.short_address == 0x9A0B
323
+ assert p.short_address == 0x0200
293
324
 
294
- p = by_serial["76495221059019"]
325
+ p = by_serial["00000000000002"]
295
326
  assert p.model == Model.VorTechMP40wG3QD
296
327
  assert p.age == 14519
297
- assert p.short_address == 0x5786
328
+ assert p.short_address == 0x0300
298
329
 
299
- p = by_serial["7V4Z00F143RBED"]
330
+ p = by_serial["FAKESERIAL0001"]
300
331
  assert p.model == Model.RadionXR15wG6Pro
301
332
  assert p.age == 8490
302
- assert p.short_address == 0x338B
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="7V4Z00F143RBED", model_raw=179, model=None,
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