python-mobius 0.1.0__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/manufacturer.py ADDED
@@ -0,0 +1,53 @@
1
+ """
2
+ Manufacturer-data parsing (panId/model/serial from BLE advertisements, no
3
+ GATT connection required) -- ported from Peripheral.parseManufactureData.
4
+
5
+ Verified byte-for-byte against real devices: decoded model and serial from
6
+ the advertisement match GATT-read values exactly. Company ID confirmed as
7
+ 0x0202 via bleak. Only the "version 2, length 25" branch (Java's bArr
8
+ length; i.e. bleak's payload length 23, since bleak strips the 2-byte
9
+ company-ID prefix Java's parser expects) is implemented, since that's the
10
+ only one actually observed on real hardware -- the decompile has other
11
+ branches for other manufacturer-data format versions/lengths that are not
12
+ implemented here. See documentation/08-manufacturer-data.md.
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import struct
18
+ from dataclasses import dataclass
19
+ from typing import Optional
20
+
21
+ from .constants import Model
22
+
23
+ MOBIUS_COMPANY_ID = 0x0202
24
+
25
+
26
+ @dataclass
27
+ class MobiusAdvertisement:
28
+ model_raw: int
29
+ model: Optional[Model]
30
+ pan_id: int
31
+ device_specific_data: int
32
+ serial: str
33
+
34
+
35
+ def parse_manufacturer_data(payload: bytes) -> Optional[MobiusAdvertisement]:
36
+ """
37
+ `payload` is bleak's AdvertisementData.manufacturer_data[MOBIUS_COMPANY_ID]
38
+ (i.e. WITHOUT the 2-byte company ID prefix -- bleak already strips it).
39
+ Only handles the 23-byte payload / Java-bArr-length-25 "version 2" format
40
+ actually observed on real hardware; returns None for anything else
41
+ rather than guessing at other branches.
42
+ """
43
+ if len(payload) != 23:
44
+ return None
45
+ model_val = struct.unpack_from("<H", payload, 0)[0]
46
+ try:
47
+ model = Model(model_val)
48
+ except ValueError:
49
+ model = None
50
+ device_specific = struct.unpack_from("<I", payload, 3)[0]
51
+ pan_id = struct.unpack_from("<H", payload, 7)[0]
52
+ serial = payload[9:23].decode("ascii", errors="replace")
53
+ return MobiusAdvertisement(model_val, model, pan_id, device_specific, serial)
mobius/schedule.py ADDED
@@ -0,0 +1,212 @@
1
+ """
2
+ Schedule point parsing for both lights and pumps -- they share the exact
3
+ same Schedule1/Schedule2 attribute and Point wire framing
4
+ (time + flags + primitiveData), but interpret primitiveData differently and
5
+ combine points differently:
6
+
7
+ - Lights (LightPrimitive): continuous curve. The app fetches the whole
8
+ schedule and linearly interpolates between the two bracketing points for
9
+ "now" -- there's no live "current intensity" read from the device at
10
+ all. See interpolate_light_schedule().
11
+
12
+ - Pumps (PumpPrimitive): discrete blocks. Point.getEnd() in the decompile
13
+ literally returns "the next point's start time" -- each point's mode
14
+ and parameters are active verbatim from its own start time until the
15
+ next point starts. See get_active_pump_block().
16
+
17
+ See documentation/06-light-schedule.md and documentation/07-pump-schedule.md.
18
+ """
19
+
20
+ from __future__ import annotations
21
+
22
+ import struct
23
+ from dataclasses import dataclass
24
+ from typing import Optional
25
+
26
+ from .constants import (
27
+ VisualID, PumpMode, RampType, PumpParam,
28
+ PUMP_PARAM_SIZE, PUMP_MODE_PARAMS, PUMP_TIME_OFFSET_PARAMS, PUMP_TIME_OFFSET,
29
+ )
30
+
31
+
32
+ # --------------------------------------------------------------------------
33
+ # Lights
34
+ # --------------------------------------------------------------------------
35
+
36
+ @dataclass
37
+ class LightPrimitive:
38
+ """A set of (channel -> intensity 0-1000 permille) pairs. Wire format:
39
+ repeating [VisualID(u8), intensity(u16 LE)] triplets."""
40
+ channels: dict # VisualID -> int (0-1000)
41
+
42
+ @classmethod
43
+ def parse(cls, data: bytes) -> "LightPrimitive":
44
+ channels = {}
45
+ for i in range(0, len(data) - 2, 3):
46
+ raw_id = data[i]
47
+ try:
48
+ vid = VisualID(raw_id)
49
+ except ValueError:
50
+ vid = VisualID.Unknown
51
+ intensity = struct.unpack_from("<H", data, i + 1)[0]
52
+ channels[vid] = intensity
53
+ return cls(channels)
54
+
55
+ def get(self, vid: VisualID) -> int:
56
+ return self.channels.get(vid, 0)
57
+
58
+
59
+ @dataclass
60
+ class SchedulePoint:
61
+ """Wire format: time(u16 LE, minutes-since-midnight) + flags(u8) + primitiveData."""
62
+ time_minutes: int
63
+ flags: int
64
+ light: LightPrimitive
65
+
66
+ FLAG_ACTIVE = 1
67
+ FLAG_NIGHT = 2
68
+ FLAG_SUNRISE = 6
69
+ FLAG_SUNSET = 10
70
+
71
+ @classmethod
72
+ def parse(cls, data: bytes) -> Optional["SchedulePoint"]:
73
+ if len(data) < 3:
74
+ return None
75
+ time_minutes = struct.unpack_from("<H", data, 0)[0]
76
+ flags = data[2]
77
+ if (flags & cls.FLAG_ACTIVE) != cls.FLAG_ACTIVE:
78
+ return None
79
+ return cls(time_minutes, flags, LightPrimitive.parse(data[3:]))
80
+
81
+ def has(self, flag: int) -> bool:
82
+ return (self.flags & flag) == flag
83
+
84
+
85
+ def interpolate_light_schedule(points: list[SchedulePoint], minute_of_day: int) -> dict:
86
+ """
87
+ Port of PointSchedule.getIntensitiesAtTime(). Given the full set of
88
+ schedule points and a target time (0-1439, minutes since midnight),
89
+ returns {VisualID: intensity_permille} by linearly interpolating between
90
+ the two points that bracket that time, wrapping around midnight.
91
+ """
92
+ if not points:
93
+ return {}
94
+ pts = sorted(points, key=lambda p: p.time_minutes)
95
+ n = len(pts)
96
+ minute_of_day %= 1440
97
+
98
+ for i in range(n):
99
+ p1 = pts[i]
100
+ p2 = pts[(i + 1) % n]
101
+ t1 = p1.time_minutes
102
+ t2 = p2.time_minutes
103
+ if (i + 1) % n == 0:
104
+ # wrapping from the last point back to the first: treat the
105
+ # first point's time as being on "the next day"
106
+ t2 += 1440
107
+
108
+ m = minute_of_day if minute_of_day >= t1 else minute_of_day + 1440
109
+ if t1 <= m <= t2:
110
+ span = (t2 - t1) or 1
111
+ frac = (m - t1) / span
112
+ result = {}
113
+ all_channels = set(p1.light.channels) | set(p2.light.channels)
114
+ for ch in all_channels:
115
+ v1 = p1.light.get(ch)
116
+ v2 = p2.light.get(ch)
117
+ result[ch] = v1 + (v2 - v1) * frac
118
+ return result
119
+ return {}
120
+
121
+
122
+ # --------------------------------------------------------------------------
123
+ # Pumps
124
+ # --------------------------------------------------------------------------
125
+
126
+ @dataclass
127
+ class PumpPrimitiveValue:
128
+ mode: PumpMode
129
+ params: dict # PumpParam -> int, or RampType for the RampType param, or bytes for Master
130
+
131
+ @classmethod
132
+ def parse(cls, data: bytes) -> "PumpPrimitiveValue":
133
+ mode_byte = data[0] if data else 0
134
+ try:
135
+ mode = PumpMode(mode_byte)
136
+ except ValueError:
137
+ mode = PumpMode.Undefined
138
+ params = {}
139
+ offset = 1
140
+ for p in PUMP_MODE_PARAMS.get(mode, []):
141
+ size = PUMP_PARAM_SIZE[p]
142
+ raw = data[offset:offset + size]
143
+ offset += size
144
+ if len(raw) < size:
145
+ continue
146
+ if p == PumpParam.RampType:
147
+ try:
148
+ params[p] = RampType(raw[0])
149
+ except ValueError:
150
+ params[p] = raw[0]
151
+ elif p == PumpParam.Master:
152
+ # last-8-bytes BLE address of the master pump this one syncs to
153
+ params[p] = raw
154
+ elif size == 2:
155
+ val = struct.unpack("<h", raw)[0]
156
+ # negative values on MaxSpeed/MinSpeed encode reverse rotation
157
+ # direction -- reported as-is here rather than replicating the
158
+ # app's separate abs()/getReverse() split, so check the sign
159
+ # yourself if it matters.
160
+ if p in PUMP_TIME_OFFSET_PARAMS:
161
+ val += PUMP_TIME_OFFSET
162
+ params[p] = val
163
+ elif size == 4:
164
+ val = struct.unpack("<i", raw)[0]
165
+ if p in PUMP_TIME_OFFSET_PARAMS:
166
+ val += PUMP_TIME_OFFSET
167
+ params[p] = val
168
+ return cls(mode, params)
169
+
170
+
171
+ @dataclass
172
+ class PumpSchedulePoint:
173
+ """Wire format identical to SchedulePoint: time(u16 LE) + flags(u8) + primitiveData,
174
+ just with primitiveData decoded as a PumpPrimitive instead of a LightPrimitive."""
175
+ time_minutes: int
176
+ flags: int
177
+ pump: PumpPrimitiveValue
178
+
179
+ @classmethod
180
+ def parse(cls, data: bytes) -> Optional["PumpSchedulePoint"]:
181
+ if len(data) < 3:
182
+ return None
183
+ time_minutes = struct.unpack_from("<H", data, 0)[0]
184
+ flags = data[2]
185
+ if (flags & SchedulePoint.FLAG_ACTIVE) != SchedulePoint.FLAG_ACTIVE:
186
+ return None
187
+ return cls(time_minutes, flags, PumpPrimitiveValue.parse(data[3:]))
188
+
189
+
190
+ def get_active_pump_block(points: list[PumpSchedulePoint], minute_of_day: int) -> Optional[PumpSchedulePoint]:
191
+ """
192
+ Unlike interpolate_light_schedule(), this does a block lookup, not a
193
+ blend: finds whichever point's [start, next_point_start) range contains
194
+ minute_of_day, wrapping at midnight. That point's mode+params are simply
195
+ "the currently active setting" -- no interpolation involved.
196
+ """
197
+ if not points:
198
+ return None
199
+ pts = sorted(points, key=lambda p: p.time_minutes)
200
+ n = len(pts)
201
+ minute_of_day %= 1440
202
+ for i in range(n):
203
+ p = pts[i]
204
+ nxt = pts[(i + 1) % n]
205
+ start = p.time_minutes
206
+ end = nxt.time_minutes
207
+ if (i + 1) % n == 0:
208
+ end += 1440
209
+ m = minute_of_day if minute_of_day >= start else minute_of_day + 1440
210
+ if start <= m < end:
211
+ return p
212
+ return pts[0]
@@ -0,0 +1,149 @@
1
+ Metadata-Version: 2.4
2
+ Name: python-mobius
3
+ Version: 0.1.0
4
+ Summary: Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)
5
+ Project-URL: Homepage, https://code.r3pek.org/r3pek/python-mobius
6
+ Project-URL: Documentation, https://code.r3pek.org/r3pek/python-mobius/src/branch/main/documentation
7
+ Project-URL: Issues, https://code.r3pek.org/r3pek/python-mobius/issues
8
+ Author: r3pek
9
+ License: GPL-2.0-only
10
+ License-File: LICENSE
11
+ Keywords: aquarium,ble,bleak,bluetooth,ecotech,fsci,mobius,radion,reef,vortech
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Intended Audience :: Developers
14
+ Classifier: License :: OSI Approved :: GNU General Public License v2 (GPLv2)
15
+ Classifier: Programming Language :: Python :: 3
16
+ Classifier: Programming Language :: Python :: 3.9
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Topic :: Home Automation
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: System :: Hardware
23
+ Requires-Python: >=3.9
24
+ Requires-Dist: bleak>=0.21
25
+ Provides-Extra: dev
26
+ Requires-Dist: bleak-retry-connector>=3.0; extra == 'dev'
27
+ Requires-Dist: pytest>=7.0; extra == 'dev'
28
+ Provides-Extra: retry
29
+ Requires-Dist: bleak-retry-connector>=3.0; extra == 'retry'
30
+ Description-Content-Type: text/markdown
31
+
32
+ # python-mobius
33
+
34
+ A reverse-engineered Python client for the BLE protocol used by "Mobius
35
+ Ready" aquarium equipment — EcoTech Marine (VorTech pumps, Radion lights),
36
+ AquaIllumination (Prime, Hydra), Neptune Systems, and NYOS.
37
+
38
+ Built on [`bleak`](https://github.com/hbldh/bleak) for cross-platform BLE.
39
+
40
+ **Not affiliated with or endorsed by any of these companies.** This is an
41
+ independent reimplementation of the wire protocol for interoperability with
42
+ hardware you own, derived from public community reverse-engineering work
43
+ and analysis of the publicly-distributed Mobius Android app. See
44
+ [`documentation/`](./documentation) for the full protocol writeup, with
45
+ every field marked as either directly confirmed or explicitly flagged as
46
+ inferred/experimental.
47
+
48
+ ## Status
49
+
50
+ Alpha. Core protocol (framing, CRC, attribute get/set, scenes), pump
51
+ telemetry, pump schedules, light schedules, and device discovery/grouping
52
+ are implemented and verified against real hardware (two VorTech MP40QD
53
+ pumps, two Radion XR15 G6 Pro lights). See
54
+ [`documentation/10-known-gaps-and-open-questions.md`](./documentation/10-known-gaps-and-open-questions.md)
55
+ for what isn't covered yet (dosers, environmental sensors, Thread/CoAP
56
+ device relay).
57
+
58
+ ## Install
59
+
60
+ ```bash
61
+ pip install python-mobius
62
+ # or, for more robust BLE connection retries (recommended):
63
+ pip install python-mobius[retry]
64
+ ```
65
+
66
+ ## Quick start
67
+
68
+ ```python
69
+ import asyncio
70
+ from mobius import scan_for_mobius_devices_with_info, group_by_pan_id, MobiusDevice
71
+
72
+ async def main():
73
+ found = await scan_for_mobius_devices_with_info()
74
+ for pan_id, members in group_by_pan_id(found).items():
75
+ print(f"tank {pan_id:#06x}:")
76
+ for device, info in members:
77
+ print(f" {device.address} {info.model.name} {info.serial}")
78
+
79
+ device, _info = found[0]
80
+ async with MobiusDevice(device) as d:
81
+ summary = await d.get_device_summary()
82
+ print(summary)
83
+
84
+ asyncio.run(main())
85
+ ```
86
+
87
+ Or from the command line:
88
+
89
+ ```bash
90
+ mobius-scan --adapter hci0
91
+ ```
92
+
93
+ ## What you can do
94
+
95
+ - **Discover devices** and group them by tank/mesh (`pan_id`), reading
96
+ model/serial straight from BLE advertisements — no connection required.
97
+ - **Read pump telemetry**: current speed, estimated flow (GPH), operation
98
+ state, error state.
99
+ - **Read pump schedules**: which mode (constant speed, tidal swell, pulse,
100
+ etc.) is active at any given time, exactly as programmed.
101
+ - **Read light schedules**: per-channel intensity at any given time,
102
+ replicating the app's own client-side interpolation (there's no "current
103
+ intensity" attribute — lights only expose the programmed curve).
104
+ - **Control scenes**: start feed mode, resume the normal schedule, or any
105
+ other configured scene.
106
+ - **Low-level protocol access** (`build_frame`, `get_attribute`,
107
+ `set_attribute`, ...) if you want to go beyond what's wrapped in
108
+ `MobiusDevice`.
109
+
110
+ ## Supported device types
111
+
112
+ | PrimitiveType | Support | Notes |
113
+ |---|---|---|
114
+ | `VisualV1` (Radion, Prime, Hydra, etc.) | ✅ Verified | Lights |
115
+ | `VorTechV1`, `PumpV1`, `VectraV1`, `AlpacaV1`, `TurtleV1` | ✅ Verified | Pumps |
116
+ | `CoffeeV1` (NYOS Quantum) | ⚠️ Experimental | Same wire structure as pumps per the protocol, untested against real hardware |
117
+ | `DoseV1`, `HotSauceV1` | ❌ Unsupported | Different primitive format; identity info only |
118
+
119
+ `MobiusDevice.get_device_summary()` always tells you which tier applies via
120
+ its `"support"` field — see [`documentation/04-device-identity.md`](./documentation/04-device-identity.md).
121
+
122
+ ## Development
123
+
124
+ ```bash
125
+ git clone https://code.r3pek.org/r3pek/python-mobius
126
+ cd python-mobius
127
+ pip install -e ".[dev]"
128
+ pytest
129
+ ```
130
+
131
+ Tests are validated against real captured packets and real device
132
+ manufacturer-data/serials where possible — see `tests/`.
133
+
134
+ ## License
135
+
136
+ GPLv2 — see [`LICENSE`](./LICENSE).
137
+
138
+ ## Acknowledgments
139
+
140
+ The protocol reverse-engineering and implementation in this library were
141
+ carried out with substantial assistance from Claude (Anthropic), used to
142
+ analyze a decompiled copy of the official Mobius Android app (JADX) and
143
+ cross-reference it against prior public community research (notably the
144
+ Reef2Reef "Controlling Mobius enabled VorTech pump using 0-10V and BLE"
145
+ thread and the `danmrossi/MobiusControl` project), then to design, write,
146
+ and test the Python implementation itself. See
147
+ [`documentation/00-overview.md`](./documentation/00-overview.md) for the
148
+ full methodology and confirmation-strength notes on every protocol
149
+ detail.
@@ -0,0 +1,14 @@
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,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.31.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mobius-scan = mobius.cli:main