python-mobius 0.1.1__tar.gz → 0.1.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 (33) hide show
  1. {python_mobius-0.1.1 → python_mobius-0.1.2}/CHANGELOG.md +24 -1
  2. {python_mobius-0.1.1 → python_mobius-0.1.2}/PKG-INFO +2 -1
  3. {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/03-attributes-and-opcodes.md +1 -1
  4. {python_mobius-0.1.1 → python_mobius-0.1.2}/pyproject.toml +3 -2
  5. {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/__init__.py +1 -1
  6. {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/device.py +41 -7
  7. python_mobius-0.1.2/tests/test_connection_cleanup.py +107 -0
  8. {python_mobius-0.1.1 → python_mobius-0.1.2}/.gitignore +0 -0
  9. {python_mobius-0.1.1 → python_mobius-0.1.2}/LICENSE +0 -0
  10. {python_mobius-0.1.1 → python_mobius-0.1.2}/README.md +0 -0
  11. {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/00-overview.md +0 -0
  12. {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/01-ble-transport.md +0 -0
  13. {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/02-framing-and-crc.md +0 -0
  14. {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/04-device-identity.md +0 -0
  15. {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/05-scenes.md +0 -0
  16. {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/06-light-schedule.md +0 -0
  17. {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/07-pump-schedule.md +0 -0
  18. {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/08-manufacturer-data.md +0 -0
  19. {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/09-thread-coap-relay.md +0 -0
  20. {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/10-known-gaps-and-open-questions.md +0 -0
  21. {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/cli.py +0 -0
  22. {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/constants.py +0 -0
  23. {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/crc.py +0 -0
  24. {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/discovery.py +0 -0
  25. {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/frame.py +0 -0
  26. {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/manufacturer.py +0 -0
  27. {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/schedule.py +0 -0
  28. {python_mobius-0.1.1 → python_mobius-0.1.2}/tests/test_frame.py +0 -0
  29. {python_mobius-0.1.1 → python_mobius-0.1.2}/tests/test_light_schedule.py +0 -0
  30. {python_mobius-0.1.1 → python_mobius-0.1.2}/tests/test_manufacturer.py +0 -0
  31. {python_mobius-0.1.1 → python_mobius-0.1.2}/tests/test_manufacturer_lookup.py +0 -0
  32. {python_mobius-0.1.1 → python_mobius-0.1.2}/tests/test_pump_schedule.py +0 -0
  33. {python_mobius-0.1.1 → python_mobius-0.1.2}/tests/test_serial_decoding.py +0 -0
@@ -1,6 +1,6 @@
1
1
  # Changelog
2
2
 
3
- ## 0.1.1
3
+ ## 0.1.2
4
4
 
5
5
  - Added `manufacturer_for_model()` / `MODEL_MANUFACTURER` (ported from
6
6
  `M.Model.getManufacturer()`) — surfaces "EcoTech Marine" /
@@ -16,6 +16,29 @@
16
16
  non-printable-ASCII edge case not yet observed. Found while building the
17
17
  Home Assistant integration on top of this library -- the hex-encoded
18
18
  serial was producing unnecessarily long/ugly entity IDs.
19
+ - `get_pump_telemetry()` now includes a confirmed `"speed_percent"` field
20
+ alongside the existing raw `"speed"`. `MotorSpeed` is confirmed (via the
21
+ decompiled app's own display code) to be a percentage of max pump power
22
+ in tenths of a percent, NOT RPM -- `String.format("%.0f%%",
23
+ Math.abs(currentSpeed / 10.0f))` is literally what the app does to this
24
+ exact value. `speed_percent` is that same `abs(speed)/10` conversion;
25
+ `speed` is still returned raw since its sign encodes rotation direction
26
+ (reverse), which `speed_percent` discards.
27
+ - Fixed a real connection leak in `MobiusDevice.connect()`: if
28
+ `start_notify()` failed after the BLE connection was already
29
+ established (a known common flaky point right after a fresh connection),
30
+ the exception propagated out of `connect()` (`__aenter__`) -- and per the
31
+ async context manager protocol, a raising `__aenter__` means `__aexit__`
32
+ (and therefore `disconnect()`) is never called by the caller's `async
33
+ with` block. The already-open connection was left orphaned on the
34
+ adapter. Since callers like Home Assistant coordinators retry
35
+ periodically, this leak compounded over repeated failures, plausibly
36
+ exhausting a real adapter's connection-slot pool over time even with no
37
+ other integration competing for it and without any range/signal issue.
38
+ `connect()` now cleans up (disconnects) before re-raising if anything
39
+ fails after the connection is established. Added regression tests
40
+ confirming both the cleanup-on-failure and no-spurious-disconnect-on-
41
+ success behavior.
19
42
 
20
43
  ## 0.1.0 — initial release
21
44
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mobius
3
- Version: 0.1.1
3
+ Version: 0.1.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
@@ -24,6 +24,7 @@ Requires-Python: >=3.9
24
24
  Requires-Dist: bleak>=0.21
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: bleak-retry-connector>=3.0; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
28
  Requires-Dist: pytest>=7.0; extra == 'dev'
28
29
  Provides-Extra: retry
29
30
  Requires-Dist: bleak-retry-connector>=3.0; extra == 'retry'
@@ -87,7 +87,7 @@ against real pumps and lights returned correctly-structured, sane data.
87
87
  | `ErrorState` | 107 | See [04-device-identity.md](./04-device-identity.md) |
88
88
  | `CurrentScene` | 401 | Set to `sceneId(u16 LE) + durationMinutes(u16 LE)` to start a scene |
89
89
  | `Schedule1` / `Schedule2` | 500 / 503 | The programmed point-schedule — see 06/07 |
90
- | `MotorSpeed` | 700 | Live pump speed (int16) |
90
+ | `MotorSpeed` | 700 | Live pump speed (int16). Confirmed NOT RPM -- percentage of max power in tenths of a percent (abs(value)/10 = %); sign encodes rotation direction. Confirmed via the app's own display code, `String.format("%.0f%%", Math.abs(currentSpeed / 10.0f))`. |
91
91
  | `SupportedColorChannels` | 901 | Static list of a light's channels |
92
92
 
93
93
  See `src/mobius/constants.py` for the complete implemented list.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-mobius"
7
- version = "0.1.1"
7
+ version = "0.1.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" }
@@ -35,7 +35,7 @@ dependencies = [
35
35
 
36
36
  [project.optional-dependencies]
37
37
  retry = ["bleak-retry-connector>=3.0"]
38
- dev = ["pytest>=7.0", "bleak-retry-connector>=3.0"]
38
+ dev = ["pytest>=7.0", "pytest-asyncio>=0.23", "bleak-retry-connector>=3.0"]
39
39
 
40
40
  [project.scripts]
41
41
  mobius-scan = "mobius.cli:main"
@@ -50,3 +50,4 @@ packages = ["src/mobius"]
50
50
 
51
51
  [tool.pytest.ini_options]
52
52
  testpaths = ["tests"]
53
+ asyncio_mode = "auto"
@@ -41,7 +41,7 @@ from .discovery import (
41
41
  scan_for_mobius_devices, scan_for_mobius_devices_with_info, group_by_pan_id,
42
42
  )
43
43
 
44
- __version__ = "0.1.1"
44
+ __version__ = "0.1.2"
45
45
 
46
46
  __all__ = [
47
47
  "__version__",
@@ -131,8 +131,29 @@ class MobiusDevice:
131
131
  if attempt == 2:
132
132
  raise
133
133
  await asyncio.sleep(2.0)
134
- await self._client.start_notify(CHAR_RX_DATA, self._on_rx_data)
135
- await self._client.start_notify(CHAR_RX_FINAL, self._on_rx_final)
134
+
135
+ # IMPORTANT: from here on, self._client holds a real, already-open
136
+ # BLE connection. If anything below fails, connect() (== __aenter__)
137
+ # raises -- and per the async context manager protocol, a raising
138
+ # __aenter__ means __aexit__/disconnect() is NEVER called by the
139
+ # caller's `async with` block. Without this try/except, a failure
140
+ # here would leak an orphaned open connection on the adapter every
141
+ # single time it happens -- and since coordinators retry on a timer,
142
+ # that leak compounds over repeated failures until the adapter's
143
+ # real connection-slot pool is exhausted, independent of range or
144
+ # any other integration. Confirmed as the likely cause of "no
145
+ # available connection slot" errors appearing over time rather than
146
+ # immediately, reported against real hardware.
147
+ try:
148
+ await self._client.start_notify(CHAR_RX_DATA, self._on_rx_data)
149
+ await self._client.start_notify(CHAR_RX_FINAL, self._on_rx_final)
150
+ except Exception:
151
+ try:
152
+ if self._client.is_connected:
153
+ await self._client.disconnect()
154
+ except Exception:
155
+ pass # best-effort cleanup; the original error is what matters
156
+ raise
136
157
 
137
158
  async def disconnect(self):
138
159
  if self._client and self._client.is_connected:
@@ -345,12 +366,24 @@ class MobiusDevice:
345
366
 
346
367
  async def get_pump_telemetry(self) -> dict:
347
368
  """
348
- Returns {"speed": int, "gph": int}. Both are genuinely read live from
349
- the device -- confirmed via FlowRange.java, which is what the app's
350
- dashboard actually queries for the pump gauge widget.
369
+ Returns {"speed": int, "speed_percent": float, "gph": int}. speed
370
+ and gph are genuinely read live from the device -- confirmed via
371
+ FlowRange.java, which is what the app's dashboard actually queries
372
+ for the pump gauge widget.
351
373
 
352
374
  "gph" comes from PhysicalValues/GallonsPerHour (int32 LE).
353
- "speed" comes from MotorSpeed directly (int16).
375
+
376
+ "speed" comes from MotorSpeed directly (int16). CONFIRMED (not
377
+ inferred) to be a percentage of max pump power in tenths of a
378
+ percent, not RPM: the app's own display code does
379
+ `String.format("%.0f%%", Math.abs(currentSpeed / 10.0f))` on this
380
+ exact value (FlowFragment.java/WidgetViewCell.java). "speed_percent"
381
+ is that same abs(speed)/10 conversion, provided as a convenience --
382
+ the sign of the raw "speed" value encodes rotation direction
383
+ (reverse), matching the same convention as the schedule's MaxSpeed
384
+ parameter (see PumpPrimitiveValue), so check the sign of "speed"
385
+ yourself if direction matters; speed_percent is always non-negative.
386
+
354
387
  MotorRPM is defined in the protocol but never queried anywhere in the
355
388
  app -- not included here since it's unconfirmed whether firmware
356
389
  actually populates it.
@@ -361,7 +394,8 @@ class MobiusDevice:
361
394
  )
362
395
  speed = struct.unpack("<h", speed_raw[0])[0] if speed_raw else None
363
396
  gph = struct.unpack("<i", gph_raw[0])[0] if gph_raw else None
364
- return {"speed": speed, "gph": gph}
397
+ speed_percent = abs(speed) / 10 if speed is not None else None
398
+ return {"speed": speed, "speed_percent": speed_percent, "gph": gph}
365
399
 
366
400
  # ---- light schedule (Radion/XR15 etc.) --------------------------------
367
401
 
@@ -0,0 +1,107 @@
1
+ """
2
+ Regression test for a real leak: if start_notify() fails after the BLE
3
+ connection is already established, connect() (== __aenter__) must clean up
4
+ the connection before re-raising -- otherwise a raising __aenter__ means
5
+ __aexit__/disconnect() is never called by the caller's `async with` block,
6
+ leaking an orphaned open connection on the adapter every time it happens.
7
+ """
8
+
9
+ from unittest.mock import AsyncMock, MagicMock, patch
10
+
11
+ import pytest
12
+ from bleak.backends.device import BLEDevice
13
+
14
+ from mobius.device import MobiusDevice
15
+
16
+
17
+ def _fake_ble_device():
18
+ return BLEDevice("AA:BB:CC:DD:EE:FF", "MOBIUS", {})
19
+
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_failed_start_notify_disconnects_before_raising():
23
+ fake_client = MagicMock()
24
+ fake_client.is_connected = True
25
+ fake_client.disconnect = AsyncMock()
26
+ fake_client.start_notify = AsyncMock(side_effect=RuntimeError("GATT notify setup failed"))
27
+
28
+ async def fake_establish_connection(*args, **kwargs):
29
+ return fake_client
30
+
31
+ with patch("mobius.device.establish_connection", side_effect=fake_establish_connection), \
32
+ patch("mobius.device._HAVE_RETRY_CONNECTOR", True):
33
+ device = MobiusDevice(_fake_ble_device())
34
+ with pytest.raises(RuntimeError, match="GATT notify setup failed"):
35
+ await device.connect()
36
+
37
+ # The key assertion: even though connect() raised, the already-open
38
+ # connection was cleaned up rather than left orphaned.
39
+ fake_client.disconnect.assert_awaited_once()
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_successful_connect_does_not_disconnect():
44
+ fake_client = MagicMock()
45
+ fake_client.is_connected = True
46
+ fake_client.disconnect = AsyncMock()
47
+ fake_client.start_notify = AsyncMock(return_value=None)
48
+
49
+ async def fake_establish_connection(*args, **kwargs):
50
+ return fake_client
51
+
52
+ with patch("mobius.device.establish_connection", side_effect=fake_establish_connection), \
53
+ patch("mobius.device._HAVE_RETRY_CONNECTOR", True):
54
+ device = MobiusDevice(_fake_ble_device())
55
+ await device.connect()
56
+
57
+ fake_client.disconnect.assert_not_awaited()
58
+ assert device._client is fake_client
59
+
60
+
61
+ @pytest.mark.asyncio
62
+ async def test_pump_telemetry_speed_percent_matches_app_formula():
63
+ """
64
+ Confirmed (not inferred) against the decompiled app's own display code:
65
+ FlowFragment.java / WidgetViewCell.java format MotorSpeed as
66
+ String.format("%.0f%%", Math.abs(currentSpeed / 10.0f)) -- i.e. speed is
67
+ a percentage of max power in tenths of a percent, not RPM, with sign
68
+ encoding rotation direction. Uses real captured values from a live
69
+ VorTech pump (speed=447 -> 44.7%).
70
+ """
71
+ fake_client = MagicMock()
72
+ fake_client.is_connected = True
73
+ fake_client.disconnect = AsyncMock()
74
+ fake_client.start_notify = AsyncMock(return_value=None)
75
+
76
+ device = MobiusDevice(_fake_ble_device())
77
+ device._client = fake_client
78
+
79
+ async def fake_get_attribute(attr_id, index=0, count=1):
80
+ if attr_id == 700: # MotorSpeed
81
+ return [(447).to_bytes(2, "little", signed=True)]
82
+ return [(2272).to_bytes(4, "little", signed=True)] # GallonsPerHour
83
+
84
+ device.get_attribute = fake_get_attribute
85
+ telemetry = await device.get_pump_telemetry()
86
+
87
+ assert telemetry["speed"] == 447
88
+ assert telemetry["speed_percent"] == 44.7
89
+ assert telemetry["gph"] == 2272
90
+
91
+
92
+ @pytest.mark.asyncio
93
+ async def test_pump_telemetry_speed_percent_is_always_nonnegative():
94
+ """A negative raw speed (reverse rotation) should still give a
95
+ non-negative speed_percent."""
96
+ device = MobiusDevice(_fake_ble_device())
97
+
98
+ async def fake_get_attribute(attr_id, index=0, count=1):
99
+ if attr_id == 700:
100
+ return [(-447).to_bytes(2, "little", signed=True)]
101
+ return [(2272).to_bytes(4, "little", signed=True)]
102
+
103
+ device.get_attribute = fake_get_attribute
104
+ telemetry = await device.get_pump_telemetry()
105
+
106
+ assert telemetry["speed"] == -447
107
+ assert telemetry["speed_percent"] == 44.7
File without changes
File without changes
File without changes