python-mobius 0.1.0__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.2/CHANGELOG.md +69 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/PKG-INFO +2 -1
- {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/03-attributes-and-opcodes.md +1 -1
- {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/10-known-gaps-and-open-questions.md +10 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/pyproject.toml +3 -2
- {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/__init__.py +5 -3
- {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/constants.py +60 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/device.py +63 -9
- python_mobius-0.1.2/tests/test_connection_cleanup.py +107 -0
- python_mobius-0.1.2/tests/test_manufacturer_lookup.py +26 -0
- python_mobius-0.1.2/tests/test_serial_decoding.py +20 -0
- python_mobius-0.1.0/CHANGELOG.md +0 -29
- {python_mobius-0.1.0 → python_mobius-0.1.2}/.gitignore +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/LICENSE +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/README.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/00-overview.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/01-ble-transport.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/02-framing-and-crc.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/04-device-identity.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/05-scenes.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/06-light-schedule.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/07-pump-schedule.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/08-manufacturer-data.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/09-thread-coap-relay.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/cli.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/crc.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/discovery.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/frame.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/manufacturer.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/schedule.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/tests/test_frame.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/tests/test_light_schedule.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/tests/test_manufacturer.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.2}/tests/test_pump_schedule.py +0 -0
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.2
|
|
4
|
+
|
|
5
|
+
- Added `manufacturer_for_model()` / `MODEL_MANUFACTURER` (ported from
|
|
6
|
+
`M.Model.getManufacturer()`) — surfaces "EcoTech Marine" /
|
|
7
|
+
"AquaIllumination" / "Neptune Systems" / "NYOS" per device model. Now
|
|
8
|
+
included automatically in `get_device_info()`'s `"manufacturer"` field.
|
|
9
|
+
Added while building a Home Assistant integration on top of this library,
|
|
10
|
+
which needs a manufacturer string for its device registry.
|
|
11
|
+
- `get_device_info()`'s `"serial"` field is now decoded as ASCII text
|
|
12
|
+
instead of hex-encoded raw bytes (e.g. `"7V4Z00F143RBED"` instead of
|
|
13
|
+
`"3756345a30304631343352424544"`). Confirmed printable ASCII on every
|
|
14
|
+
device tested, via cross-validation against the same serial parsed from
|
|
15
|
+
BLE advertisement manufacturer data. Falls back to hex for any
|
|
16
|
+
non-printable-ASCII edge case not yet observed. Found while building the
|
|
17
|
+
Home Assistant integration on top of this library -- the hex-encoded
|
|
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.
|
|
42
|
+
|
|
43
|
+
## 0.1.0 — initial release
|
|
44
|
+
|
|
45
|
+
- Core FSCI/C2CI wire protocol: framing, CRC16, Get/Set-attribute codec.
|
|
46
|
+
Verified byte-for-byte against real captured packets.
|
|
47
|
+
- Scene control (`start_scene`, `start_feed_mode`, `resume_schedule`).
|
|
48
|
+
- Pump telemetry (speed, estimated GPH) — verified live against real
|
|
49
|
+
VorTech MP40QD pumps.
|
|
50
|
+
- Pump schedule reading (mode + parameters per time block) — verified live.
|
|
51
|
+
- Light schedule reading + client-side interpolation, matching the app's
|
|
52
|
+
own behavior — verified live against real Radion XR15 G6 Pro lights.
|
|
53
|
+
- Device discovery, identity (`get_device_info`), and a unified
|
|
54
|
+
`get_device_summary()` with explicit support tiers
|
|
55
|
+
(light / pump / pump-experimental / unsupported).
|
|
56
|
+
- Advertisement-only discovery: model, serial, and `pan_id` (tank/mesh
|
|
57
|
+
grouping) with no GATT connection required — verified against 4 real
|
|
58
|
+
devices sharing one `pan_id`.
|
|
59
|
+
- `mobius-scan` CLI.
|
|
60
|
+
|
|
61
|
+
### Known gaps
|
|
62
|
+
|
|
63
|
+
- `DoseV1` and `HotSauceV1` primitive types have no parser (see
|
|
64
|
+
`documentation/10-known-gaps-and-open-questions.md`).
|
|
65
|
+
- `CoffeeV1` (NYOS Quantum) pump-primitive support is structurally
|
|
66
|
+
plausible but unverified against real hardware.
|
|
67
|
+
- Thread/CoAP device-to-device relay (used by the official app to reach
|
|
68
|
+
devices without a direct BLE connection) is not implemented — this
|
|
69
|
+
library always connects directly to each device instead.
|
|
@@ -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.
|
{python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/10-known-gaps-and-open-questions.md
RENAMED
|
@@ -34,6 +34,16 @@ included as a starting point.
|
|
|
34
34
|
- **`MACAddress`** (attribute 103): returned empty/unpopulated on every
|
|
35
35
|
device tested. Might be Thread/legacy-radio-only and simply not set on
|
|
36
36
|
BLE-only-provisioned devices, or might need a different query context.
|
|
37
|
+
- **The unit of `PhysicalValueID.GallonsPerHour`**: high confidence it's
|
|
38
|
+
genuinely US gallons per hour, not empirically flow-metered. Basis: (1)
|
|
39
|
+
the decompiled source's own attribute name is literally
|
|
40
|
+
`GallonsPerHour`, EcoTech's own naming, not an inference; (2) EcoTech is
|
|
41
|
+
a US company that markets VorTech flow exclusively in GPH; (3) real
|
|
42
|
+
captured values (2,272 and 2,877 GPH, both from the same partial-power
|
|
43
|
+
speed setting) land at roughly 45-65% of the MP40wQD's own published
|
|
44
|
+
4,500-5,000+ GPH max rating -- exactly the proportion expected for a
|
|
45
|
+
pump running below full power. No independent flow-meter verification
|
|
46
|
+
has been done against real hardware.
|
|
37
47
|
|
|
38
48
|
## Deliberately not implemented
|
|
39
49
|
|
|
@@ -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"
|
|
@@ -23,7 +23,8 @@ from .frame import (
|
|
|
23
23
|
from .constants import (
|
|
24
24
|
PrimitiveType, PRIMITIVE_SIZE, LIGHT_PRIMITIVES,
|
|
25
25
|
PUMP_PRIMITIVES_VERIFIED, PUMP_PRIMITIVES_EXPERIMENTAL,
|
|
26
|
-
Model,
|
|
26
|
+
Model, MODEL_MANUFACTURER, manufacturer_for_model,
|
|
27
|
+
ErrorState, C2Attribute, PhysicalValueID, VisualID,
|
|
27
28
|
SceneID, OperationState, FsciStatus,
|
|
28
29
|
PumpMode, RampType, PumpParam, PUMP_PARAM_SIZE, PUMP_MODE_PARAMS,
|
|
29
30
|
)
|
|
@@ -40,7 +41,7 @@ from .discovery import (
|
|
|
40
41
|
scan_for_mobius_devices, scan_for_mobius_devices_with_info, group_by_pan_id,
|
|
41
42
|
)
|
|
42
43
|
|
|
43
|
-
__version__ = "0.1.
|
|
44
|
+
__version__ = "0.1.2"
|
|
44
45
|
|
|
45
46
|
__all__ = [
|
|
46
47
|
"__version__",
|
|
@@ -57,7 +58,8 @@ __all__ = [
|
|
|
57
58
|
# constants
|
|
58
59
|
"PrimitiveType", "PRIMITIVE_SIZE", "LIGHT_PRIMITIVES",
|
|
59
60
|
"PUMP_PRIMITIVES_VERIFIED", "PUMP_PRIMITIVES_EXPERIMENTAL",
|
|
60
|
-
"Model", "
|
|
61
|
+
"Model", "MODEL_MANUFACTURER", "manufacturer_for_model",
|
|
62
|
+
"ErrorState", "C2Attribute", "PhysicalValueID", "VisualID",
|
|
61
63
|
"SceneID", "OperationState", "FsciStatus",
|
|
62
64
|
"PumpMode", "RampType", "PumpParam", "PUMP_PARAM_SIZE", "PUMP_MODE_PARAMS",
|
|
63
65
|
# schedule
|
|
@@ -11,6 +11,7 @@ See documentation/ for the full derivation of each table.
|
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
13
|
from enum import IntEnum
|
|
14
|
+
from typing import Optional
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
# --------------------------------------------------------------------------
|
|
@@ -107,6 +108,65 @@ class Model(IntEnum):
|
|
|
107
108
|
Coffee2 = 397 # "Quantum 220/300" (NYOS)
|
|
108
109
|
|
|
109
110
|
|
|
111
|
+
# Ported from M.Model.getManufacturer(). The decompiled version branches on
|
|
112
|
+
# enum *ordinal* against a hardcoded position list (the full ~100-entry
|
|
113
|
+
# Model enum's index 1-37 range, plus a few extras, all map to "EcoTech
|
|
114
|
+
# Marine"); since this library only implements a curated subset of Model,
|
|
115
|
+
# this is re-derived by name pattern against that same logic instead of by
|
|
116
|
+
# ordinal position -- functionally equivalent for every Model value defined
|
|
117
|
+
# above, but if you add more Model entries, add their manufacturer here too.
|
|
118
|
+
MODEL_MANUFACTURER: dict[Model, str] = {
|
|
119
|
+
Model.Unknown: "Unknown",
|
|
120
|
+
Model.VorTechMP10wES: "EcoTech Marine",
|
|
121
|
+
Model.VorTechMP10wQD: "EcoTech Marine",
|
|
122
|
+
Model.VorTechMP40wES: "EcoTech Marine",
|
|
123
|
+
Model.VorTechMP40wQD: "EcoTech Marine",
|
|
124
|
+
Model.VorTechMP40wG3QD: "EcoTech Marine",
|
|
125
|
+
Model.VorTechMP60wES: "EcoTech Marine",
|
|
126
|
+
Model.VorTechMP60wQD: "EcoTech Marine",
|
|
127
|
+
Model.RadionXR30w: "EcoTech Marine",
|
|
128
|
+
Model.RadionXR30wG2: "EcoTech Marine",
|
|
129
|
+
Model.RadionXR30wPro: "EcoTech Marine",
|
|
130
|
+
Model.RadionXR30wG3: "EcoTech Marine",
|
|
131
|
+
Model.RadionXR30wG3Pro: "EcoTech Marine",
|
|
132
|
+
Model.RadionXR30wG4: "EcoTech Marine",
|
|
133
|
+
Model.RadionXR30wG4Pro: "EcoTech Marine",
|
|
134
|
+
Model.RadionXR30wFWPro: "EcoTech Marine",
|
|
135
|
+
Model.RadionXR30wPrototype: "EcoTech Marine",
|
|
136
|
+
Model.RadionXR15wG3Pro: "EcoTech Marine",
|
|
137
|
+
Model.RadionXR15wFW: "EcoTech Marine",
|
|
138
|
+
Model.RadionXR15wG4Pro: "EcoTech Marine",
|
|
139
|
+
Model.RadionXR15wFWPro: "EcoTech Marine",
|
|
140
|
+
Model.RadionXR15wPrototype: "EcoTech Marine",
|
|
141
|
+
Model.RadionXR15G5Pro: "EcoTech Marine",
|
|
142
|
+
Model.RadionXR15wG5Blue: "EcoTech Marine",
|
|
143
|
+
Model.RadionXR15wG5FW: "EcoTech Marine",
|
|
144
|
+
Model.RadionXR15wG6Pro: "EcoTech Marine",
|
|
145
|
+
Model.RadionXR15wG6Blue: "EcoTech Marine",
|
|
146
|
+
Model.RadionXR30wG5Pro: "EcoTech Marine",
|
|
147
|
+
Model.RadionXR30wG5Blue: "EcoTech Marine",
|
|
148
|
+
Model.VectraM1: "EcoTech Marine",
|
|
149
|
+
Model.VectraL1: "EcoTech Marine",
|
|
150
|
+
Model.VectraS1: "EcoTech Marine",
|
|
151
|
+
Model.VectraS2: "EcoTech Marine",
|
|
152
|
+
Model.VectraM2: "EcoTech Marine",
|
|
153
|
+
Model.VectraL2: "EcoTech Marine",
|
|
154
|
+
Model.Nero5: "AquaIllumination",
|
|
155
|
+
Model.Nero3: "AquaIllumination",
|
|
156
|
+
Model.DosingPump: "AquaIllumination",
|
|
157
|
+
Model.Muffler: "Neptune Systems", # confirmed explicitly in the decompile's manufacturer list
|
|
158
|
+
Model.Coffee1: "NYOS", # confirmed explicitly (CoffeeV1 branch)
|
|
159
|
+
Model.Coffee2: "NYOS",
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def manufacturer_for_model(model: Optional[Model]) -> Optional[str]:
|
|
164
|
+
"""Returns the manufacturer name for a Model, or None if unknown/unmapped."""
|
|
165
|
+
if model is None:
|
|
166
|
+
return None
|
|
167
|
+
return MODEL_MANUFACTURER.get(model)
|
|
168
|
+
|
|
169
|
+
|
|
110
170
|
class ErrorState(IntEnum):
|
|
111
171
|
"""Confirmed literal values from M.ErrorState (subset covering common
|
|
112
172
|
pump/light fault codes -- the full enum also has dosing-pump-specific codes)."""
|
|
@@ -29,7 +29,7 @@ from .frame import (
|
|
|
29
29
|
)
|
|
30
30
|
from .constants import (
|
|
31
31
|
C2Attribute, PrimitiveType, Model, ErrorState, SceneID, OperationState,
|
|
32
|
-
FsciStatus, PhysicalValueID, VisualID,
|
|
32
|
+
FsciStatus, PhysicalValueID, VisualID, manufacturer_for_model,
|
|
33
33
|
PRIMITIVE_SIZE, LIGHT_PRIMITIVES, PUMP_PRIMITIVES_VERIFIED, PUMP_PRIMITIVES_EXPERIMENTAL,
|
|
34
34
|
)
|
|
35
35
|
from .schedule import (
|
|
@@ -48,6 +48,25 @@ CHAR_TX_DATA = "01ff0103-ba5e-f4ee-5ca1-eb1e5e4b1ce0" # write: app -> device,
|
|
|
48
48
|
CHAR_TX_FINAL = "01ff0104-ba5e-f4ee-5ca1-eb1e5e4b1ce0" # write: app -> device, last fragment
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
def _decode_serial(raw: bytes) -> str:
|
|
52
|
+
"""
|
|
53
|
+
Serial numbers are printable ASCII on every device tested (confirmed by
|
|
54
|
+
cross-validating the GATT-read serial against the same serial parsed
|
|
55
|
+
from BLE advertisement manufacturer data -- see manufacturer.py and
|
|
56
|
+
documentation/08-manufacturer-data.md), so decode as text rather than
|
|
57
|
+
hex-encoding raw bytes. Falls back to hex for any non-printable-ASCII
|
|
58
|
+
edge case not yet observed, rather than raising.
|
|
59
|
+
"""
|
|
60
|
+
text = raw.rstrip(b"\x00")
|
|
61
|
+
try:
|
|
62
|
+
decoded = text.decode("ascii")
|
|
63
|
+
except UnicodeDecodeError:
|
|
64
|
+
return raw.hex()
|
|
65
|
+
if decoded and all(32 <= ord(c) < 127 for c in decoded):
|
|
66
|
+
return decoded
|
|
67
|
+
return raw.hex()
|
|
68
|
+
|
|
69
|
+
|
|
51
70
|
class MobiusDevice:
|
|
52
71
|
"""
|
|
53
72
|
High-level async client for a single Mobius-protocol BLE device.
|
|
@@ -112,8 +131,29 @@ class MobiusDevice:
|
|
|
112
131
|
if attempt == 2:
|
|
113
132
|
raise
|
|
114
133
|
await asyncio.sleep(2.0)
|
|
115
|
-
|
|
116
|
-
|
|
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
|
|
117
157
|
|
|
118
158
|
async def disconnect(self):
|
|
119
159
|
if self._client and self._client.is_connected:
|
|
@@ -252,8 +292,9 @@ class MobiusDevice:
|
|
|
252
292
|
return {
|
|
253
293
|
"model_raw": model_val,
|
|
254
294
|
"model": model.name if model is not None else (f"unknown({model_val})" if model_val is not None else None),
|
|
295
|
+
"manufacturer": manufacturer_for_model(model),
|
|
255
296
|
"name": name_raw.decode("utf-8", errors="replace").rstrip("\x00") if name_raw else None,
|
|
256
|
-
"serial": serial_raw
|
|
297
|
+
"serial": _decode_serial(serial_raw) if serial_raw else None,
|
|
257
298
|
"primitive_type": PrimitiveType(primitive_raw[0]).name if primitive_raw else None,
|
|
258
299
|
"error_state": error.name if error is not None else (f"unknown({error_val})" if error_val is not None else None),
|
|
259
300
|
"mac_address": ":".join(f"{b:02X}" for b in mac_raw) if mac_raw else None,
|
|
@@ -325,12 +366,24 @@ class MobiusDevice:
|
|
|
325
366
|
|
|
326
367
|
async def get_pump_telemetry(self) -> dict:
|
|
327
368
|
"""
|
|
328
|
-
Returns {"speed": int, "gph": int}.
|
|
329
|
-
|
|
330
|
-
|
|
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.
|
|
331
373
|
|
|
332
374
|
"gph" comes from PhysicalValues/GallonsPerHour (int32 LE).
|
|
333
|
-
|
|
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
|
+
|
|
334
387
|
MotorRPM is defined in the protocol but never queried anywhere in the
|
|
335
388
|
app -- not included here since it's unconfirmed whether firmware
|
|
336
389
|
actually populates it.
|
|
@@ -341,7 +394,8 @@ class MobiusDevice:
|
|
|
341
394
|
)
|
|
342
395
|
speed = struct.unpack("<h", speed_raw[0])[0] if speed_raw else None
|
|
343
396
|
gph = struct.unpack("<i", gph_raw[0])[0] if gph_raw else None
|
|
344
|
-
|
|
397
|
+
speed_percent = abs(speed) / 10 if speed is not None else None
|
|
398
|
+
return {"speed": speed, "speed_percent": speed_percent, "gph": gph}
|
|
345
399
|
|
|
346
400
|
# ---- light schedule (Radion/XR15 etc.) --------------------------------
|
|
347
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
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from mobius import Model, manufacturer_for_model
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def test_vortech_is_ecotech_marine():
|
|
5
|
+
assert manufacturer_for_model(Model.VorTechMP40wG3QD) == "EcoTech Marine"
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def test_radion_is_ecotech_marine():
|
|
9
|
+
assert manufacturer_for_model(Model.RadionXR15wG6Pro) == "EcoTech Marine"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def test_nero_is_aquaillumination():
|
|
13
|
+
assert manufacturer_for_model(Model.Nero5) == "AquaIllumination"
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def test_coffee_is_nyos():
|
|
17
|
+
assert manufacturer_for_model(Model.Coffee1) == "NYOS"
|
|
18
|
+
assert manufacturer_for_model(Model.Coffee2) == "NYOS"
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def test_muffler_is_neptune_systems():
|
|
22
|
+
assert manufacturer_for_model(Model.Muffler) == "Neptune Systems"
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def test_none_model_returns_none():
|
|
26
|
+
assert manufacturer_for_model(None) is None
|
|
@@ -0,0 +1,20 @@
|
|
|
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()
|
python_mobius-0.1.0/CHANGELOG.md
DELETED
|
@@ -1,29 +0,0 @@
|
|
|
1
|
-
# Changelog
|
|
2
|
-
|
|
3
|
-
## 0.1.0 — initial release
|
|
4
|
-
|
|
5
|
-
- Core FSCI/C2CI wire protocol: framing, CRC16, Get/Set-attribute codec.
|
|
6
|
-
Verified byte-for-byte against real captured packets.
|
|
7
|
-
- Scene control (`start_scene`, `start_feed_mode`, `resume_schedule`).
|
|
8
|
-
- Pump telemetry (speed, estimated GPH) — verified live against real
|
|
9
|
-
VorTech MP40QD pumps.
|
|
10
|
-
- Pump schedule reading (mode + parameters per time block) — verified live.
|
|
11
|
-
- Light schedule reading + client-side interpolation, matching the app's
|
|
12
|
-
own behavior — verified live against real Radion XR15 G6 Pro lights.
|
|
13
|
-
- Device discovery, identity (`get_device_info`), and a unified
|
|
14
|
-
`get_device_summary()` with explicit support tiers
|
|
15
|
-
(light / pump / pump-experimental / unsupported).
|
|
16
|
-
- Advertisement-only discovery: model, serial, and `pan_id` (tank/mesh
|
|
17
|
-
grouping) with no GATT connection required — verified against 4 real
|
|
18
|
-
devices sharing one `pan_id`.
|
|
19
|
-
- `mobius-scan` CLI.
|
|
20
|
-
|
|
21
|
-
### Known gaps
|
|
22
|
-
|
|
23
|
-
- `DoseV1` and `HotSauceV1` primitive types have no parser (see
|
|
24
|
-
`documentation/10-known-gaps-and-open-questions.md`).
|
|
25
|
-
- `CoffeeV1` (NYOS Quantum) pump-primitive support is structurally
|
|
26
|
-
plausible but unverified against real hardware.
|
|
27
|
-
- Thread/CoAP device-to-device relay (used by the official app to reach
|
|
28
|
-
devices without a direct BLE connection) is not implemented — this
|
|
29
|
-
library always connects directly to each device instead.
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|