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.
- {python_mobius-0.1.1 → python_mobius-0.1.2}/CHANGELOG.md +24 -1
- {python_mobius-0.1.1 → python_mobius-0.1.2}/PKG-INFO +2 -1
- {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/03-attributes-and-opcodes.md +1 -1
- {python_mobius-0.1.1 → python_mobius-0.1.2}/pyproject.toml +3 -2
- {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/__init__.py +1 -1
- {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/device.py +41 -7
- python_mobius-0.1.2/tests/test_connection_cleanup.py +107 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/.gitignore +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/LICENSE +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/README.md +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/00-overview.md +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/01-ble-transport.md +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/02-framing-and-crc.md +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/04-device-identity.md +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/05-scenes.md +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/06-light-schedule.md +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/07-pump-schedule.md +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/08-manufacturer-data.md +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/09-thread-coap-relay.md +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/documentation/10-known-gaps-and-open-questions.md +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/cli.py +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/constants.py +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/crc.py +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/discovery.py +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/frame.py +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/manufacturer.py +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/src/mobius/schedule.py +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/tests/test_frame.py +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/tests/test_light_schedule.py +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/tests/test_manufacturer.py +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/tests/test_manufacturer_lookup.py +0 -0
- {python_mobius-0.1.1 → python_mobius-0.1.2}/tests/test_pump_schedule.py +0 -0
- {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.
|
|
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.
|
|
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.
|
|
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"
|
|
@@ -131,8 +131,29 @@ class MobiusDevice:
|
|
|
131
131
|
if attempt == 2:
|
|
132
132
|
raise
|
|
133
133
|
await asyncio.sleep(2.0)
|
|
134
|
-
|
|
135
|
-
|
|
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}.
|
|
349
|
-
|
|
350
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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.1.1 → python_mobius-0.1.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
|