python-mobius 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl
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.
- mobius/__init__.py +5 -3
- mobius/constants.py +60 -0
- mobius/device.py +63 -9
- {python_mobius-0.1.0.dist-info → python_mobius-0.1.2.dist-info}/METADATA +2 -1
- python_mobius-0.1.2.dist-info/RECORD +14 -0
- python_mobius-0.1.0.dist-info/RECORD +0 -14
- {python_mobius-0.1.0.dist-info → python_mobius-0.1.2.dist-info}/WHEEL +0 -0
- {python_mobius-0.1.0.dist-info → python_mobius-0.1.2.dist-info}/entry_points.txt +0 -0
- {python_mobius-0.1.0.dist-info → python_mobius-0.1.2.dist-info}/licenses/LICENSE +0 -0
mobius/__init__.py
CHANGED
|
@@ -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
|
mobius/constants.py
CHANGED
|
@@ -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)."""
|
mobius/device.py
CHANGED
|
@@ -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
|
|
|
@@ -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'
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
mobius/__init__.py,sha256=2TkA717bDRzec96psyBxp1NEUi12azr93DxI0iYSfrY,3381
|
|
2
|
+
mobius/cli.py,sha256=uhGZIDrhpTp8LkTGOl4BZVQWgJYHbaIVH2HzX3RFCCs,2642
|
|
3
|
+
mobius/constants.py,sha256=wKrWU-QnqMuWuvczHwt3ha3sPw6D9NNK-kIhxJcTbHA,14702
|
|
4
|
+
mobius/crc.py,sha256=WwIfUXGN_UKdsv-_Q303-L1-L1Rc1fvJnB5176ftzqU,2669
|
|
5
|
+
mobius/device.py,sha256=3kR34RLQYd3lbqG8HeRC5HMvD7k5A6XpBfu_eyASNVw,22779
|
|
6
|
+
mobius/discovery.py,sha256=4MjGwpd0bUK-koiZ-a2Cvq-gCTImAKAQ4lZ-9vOge3Q,2290
|
|
7
|
+
mobius/frame.py,sha256=zG2vh8PceJHD8R43MwZiJnm0Ge1dr8nFcKsJR9_c9LM,6135
|
|
8
|
+
mobius/manufacturer.py,sha256=8-vBzckN8xmsPSMj5EWtOCXenbyQueoY5pnZHePgb_k,1937
|
|
9
|
+
mobius/schedule.py,sha256=kHr684TIKPZ2BplIySoeD-Yn1Xa_2bE6rfGUs-waWMQ,7483
|
|
10
|
+
python_mobius-0.1.2.dist-info/METADATA,sha256=ZfFhhqMEx8S5lyZumY2nMBlmO2bW0HrDtwFi8_gKbbE,5959
|
|
11
|
+
python_mobius-0.1.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
12
|
+
python_mobius-0.1.2.dist-info/entry_points.txt,sha256=Q2hlcek-bWm70zvd12v32essflPgC2su-dKDHAqbPoE,48
|
|
13
|
+
python_mobius-0.1.2.dist-info/licenses/LICENSE,sha256=7a72Msu2Q-TnoiFxemxEGkwafJGObk1W3rw9hzmyM_Y,17984
|
|
14
|
+
python_mobius-0.1.2.dist-info/RECORD,,
|
|
@@ -1,14 +0,0 @@
|
|
|
1
|
-
mobius/__init__.py,sha256=wtCy-JKP5KhFr3dq_C0V4458R-msyZt2EP96vH16Tr8,3281
|
|
2
|
-
mobius/cli.py,sha256=uhGZIDrhpTp8LkTGOl4BZVQWgJYHbaIVH2HzX3RFCCs,2642
|
|
3
|
-
mobius/constants.py,sha256=9OtNEOU4FXXhdgayGrz5e_T8s6M20UE31B6GQ-mywnY,12022
|
|
4
|
-
mobius/crc.py,sha256=WwIfUXGN_UKdsv-_Q303-L1-L1Rc1fvJnB5176ftzqU,2669
|
|
5
|
-
mobius/device.py,sha256=CbCQQjmLQmg-zFufRufcRPkA3PlO_18AkeCP_eDE0EA,19993
|
|
6
|
-
mobius/discovery.py,sha256=4MjGwpd0bUK-koiZ-a2Cvq-gCTImAKAQ4lZ-9vOge3Q,2290
|
|
7
|
-
mobius/frame.py,sha256=zG2vh8PceJHD8R43MwZiJnm0Ge1dr8nFcKsJR9_c9LM,6135
|
|
8
|
-
mobius/manufacturer.py,sha256=8-vBzckN8xmsPSMj5EWtOCXenbyQueoY5pnZHePgb_k,1937
|
|
9
|
-
mobius/schedule.py,sha256=kHr684TIKPZ2BplIySoeD-Yn1Xa_2bE6rfGUs-waWMQ,7483
|
|
10
|
-
python_mobius-0.1.0.dist-info/METADATA,sha256=pwoXT7fGt7RyDQKMvRzjvNJZaaFtzENzOUWI_OwmELs,5907
|
|
11
|
-
python_mobius-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
12
|
-
python_mobius-0.1.0.dist-info/entry_points.txt,sha256=Q2hlcek-bWm70zvd12v32essflPgC2su-dKDHAqbPoE,48
|
|
13
|
-
python_mobius-0.1.0.dist-info/licenses/LICENSE,sha256=7a72Msu2Q-TnoiFxemxEGkwafJGObk1W3rw9hzmyM_Y,17984
|
|
14
|
-
python_mobius-0.1.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|