python-mobius 0.4.0__tar.gz → 0.4.1__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 (64) hide show
  1. {python_mobius-0.4.0 → python_mobius-0.4.1}/.forgejo/workflows/release.yml +25 -20
  2. {python_mobius-0.4.0 → python_mobius-0.4.1}/CHANGELOG.md +5 -0
  3. {python_mobius-0.4.0 → python_mobius-0.4.1}/PKG-INFO +1 -1
  4. {python_mobius-0.4.0 → python_mobius-0.4.1}/pyproject.toml +1 -1
  5. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/__init__.py +1 -1
  6. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/device.py +26 -6
  7. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/discovery.py +31 -7
  8. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_mesh_discovery.py +72 -1
  9. {python_mobius-0.4.0 → python_mobius-0.4.1}/.forgejo/scripts/extract_changelog_section.py +0 -0
  10. {python_mobius-0.4.0 → python_mobius-0.4.1}/.gitignore +0 -0
  11. {python_mobius-0.4.0 → python_mobius-0.4.1}/LICENSE +0 -0
  12. {python_mobius-0.4.0 → python_mobius-0.4.1}/README.md +0 -0
  13. {python_mobius-0.4.0 → python_mobius-0.4.1}/documentation/00-overview.md +0 -0
  14. {python_mobius-0.4.0 → python_mobius-0.4.1}/documentation/01-ble-transport.md +0 -0
  15. {python_mobius-0.4.0 → python_mobius-0.4.1}/documentation/02-framing-and-crc.md +0 -0
  16. {python_mobius-0.4.0 → python_mobius-0.4.1}/documentation/03-attributes-and-opcodes.md +0 -0
  17. {python_mobius-0.4.0 → python_mobius-0.4.1}/documentation/04-device-identity.md +0 -0
  18. {python_mobius-0.4.0 → python_mobius-0.4.1}/documentation/05-scenes.md +0 -0
  19. {python_mobius-0.4.0 → python_mobius-0.4.1}/documentation/06-light-schedule.md +0 -0
  20. {python_mobius-0.4.0 → python_mobius-0.4.1}/documentation/07-pump-schedule.md +0 -0
  21. {python_mobius-0.4.0 → python_mobius-0.4.1}/documentation/08-manufacturer-data.md +0 -0
  22. {python_mobius-0.4.0 → python_mobius-0.4.1}/documentation/09-thread-coap-relay.md +0 -0
  23. {python_mobius-0.4.0 → python_mobius-0.4.1}/documentation/10-known-gaps-and-open-questions.md +0 -0
  24. {python_mobius-0.4.0 → python_mobius-0.4.1}/documentation/11-device-status-attributes.md +0 -0
  25. {python_mobius-0.4.0 → python_mobius-0.4.1}/documentation/12-device-identity-and-address-stability.md +0 -0
  26. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/cli.py +0 -0
  27. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/coap.py +0 -0
  28. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/constants.py +0 -0
  29. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/crc.py +0 -0
  30. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/device_status.py +0 -0
  31. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/frame.py +0 -0
  32. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/manufacturer.py +0 -0
  33. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/mesh_address.py +0 -0
  34. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/modifiers.py +0 -0
  35. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/power.py +0 -0
  36. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/pump_status.py +0 -0
  37. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/relay.py +0 -0
  38. {python_mobius-0.4.0 → python_mobius-0.4.1}/src/mobius/schedule.py +0 -0
  39. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_coap.py +0 -0
  40. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_coap_indication_handling.py +0 -0
  41. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_connection_cleanup.py +0 -0
  42. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_device_status.py +0 -0
  43. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_device_summary.py +0 -0
  44. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_device_time.py +0 -0
  45. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_firmware_versions.py +0 -0
  46. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_frame.py +0 -0
  47. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_frame_sniffer.py +0 -0
  48. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_hyperdrive_device.py +0 -0
  49. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_insolation_detection.py +0 -0
  50. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_light_intensity_branching.py +0 -0
  51. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_light_schedule.py +0 -0
  52. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_manufacturer.py +0 -0
  53. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_manufacturer_lookup.py +0 -0
  54. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_mesh_address.py +0 -0
  55. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_mesh_discovery_direct_connect.py +0 -0
  56. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_modifiers.py +0 -0
  57. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_networked_thread_devices.py +0 -0
  58. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_power.py +0 -0
  59. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_pump_schedule.py +0 -0
  60. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_pump_status.py +0 -0
  61. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_relay.py +0 -0
  62. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_schedule_intensity_scalar.py +0 -0
  63. {python_mobius-0.4.0 → python_mobius-0.4.1}/tests/test_serial_decoding.py +0 -0
  64. {python_mobius-0.4.0 → python_mobius-0.4.1}/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,10 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.4.1
4
+
5
+ - Fixed `discover_tank()` sometimes reporting the connected device
6
+ twice in a tank's peer list.
7
+
3
8
  ## 0.4.0
4
9
 
5
10
  - **`mobius-scan`'s default now also connects to ONE device and dumps
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mobius
3
- Version: 0.4.0
3
+ Version: 0.4.1
4
4
  Summary: Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)
5
5
  Project-URL: Homepage, https://code.r3pek.org/r3pek/python-mobius
6
6
  Project-URL: Documentation, https://code.r3pek.org/r3pek/python-mobius/src/branch/main/documentation
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-mobius"
7
- version = "0.4.0"
7
+ version = "0.4.1"
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.1"
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
@@ -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
 
@@ -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():
@@ -295,6 +295,77 @@ async def test_discover_tank_basic():
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 = "76495221059019"
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="76517731952041", model_raw=Model.VorTechMP40wG3QD.value,
332
+ model=Model.VorTechMP40wG3QD, short_address=0x9a0b,
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 "76517731952041" 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 --
File without changes
File without changes
File without changes