python-mobius 0.1.0__tar.gz → 0.1.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.
- {python_mobius-0.1.0 → python_mobius-0.1.1}/CHANGELOG.md +17 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/PKG-INFO +1 -1
- {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/10-known-gaps-and-open-questions.md +10 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/pyproject.toml +1 -1
- {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/__init__.py +5 -3
- {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/constants.py +60 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/device.py +22 -2
- python_mobius-0.1.1/tests/test_manufacturer_lookup.py +26 -0
- python_mobius-0.1.1/tests/test_serial_decoding.py +20 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/.gitignore +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/LICENSE +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/README.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/00-overview.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/01-ble-transport.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/02-framing-and-crc.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/03-attributes-and-opcodes.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/04-device-identity.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/05-scenes.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/06-light-schedule.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/07-pump-schedule.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/08-manufacturer-data.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/09-thread-coap-relay.md +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/cli.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/crc.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/discovery.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/frame.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/manufacturer.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/schedule.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/tests/test_frame.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/tests/test_light_schedule.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/tests/test_manufacturer.py +0 -0
- {python_mobius-0.1.0 → python_mobius-0.1.1}/tests/test_pump_schedule.py +0 -0
|
@@ -1,5 +1,22 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1
|
|
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
|
+
|
|
3
20
|
## 0.1.0 — initial release
|
|
4
21
|
|
|
5
22
|
- Core FSCI/C2CI wire protocol: framing, CRC16, Get/Set-attribute codec.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-mobius
|
|
3
|
-
Version: 0.1.
|
|
3
|
+
Version: 0.1.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
|
{python_mobius-0.1.0 → python_mobius-0.1.1}/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.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" }
|
|
@@ -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.1"
|
|
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.
|
|
@@ -252,8 +271,9 @@ class MobiusDevice:
|
|
|
252
271
|
return {
|
|
253
272
|
"model_raw": model_val,
|
|
254
273
|
"model": model.name if model is not None else (f"unknown({model_val})" if model_val is not None else None),
|
|
274
|
+
"manufacturer": manufacturer_for_model(model),
|
|
255
275
|
"name": name_raw.decode("utf-8", errors="replace").rstrip("\x00") if name_raw else None,
|
|
256
|
-
"serial": serial_raw
|
|
276
|
+
"serial": _decode_serial(serial_raw) if serial_raw else None,
|
|
257
277
|
"primitive_type": PrimitiveType(primitive_raw[0]).name if primitive_raw else None,
|
|
258
278
|
"error_state": error.name if error is not None else (f"unknown({error_val})" if error_val is not None else None),
|
|
259
279
|
"mac_address": ":".join(f"{b:02X}" for b in mac_raw) if mac_raw else None,
|
|
@@ -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()
|
|
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
|
|
File without changes
|