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/__init__.py +73 -0
- mobius/cli.py +68 -0
- mobius/constants.py +393 -0
- mobius/crc.py +49 -0
- mobius/device.py +429 -0
- mobius/discovery.py +64 -0
- mobius/frame.py +170 -0
- mobius/manufacturer.py +53 -0
- mobius/schedule.py +212 -0
- python_mobius-0.1.0.dist-info/METADATA +149 -0
- python_mobius-0.1.0.dist-info/RECORD +14 -0
- python_mobius-0.1.0.dist-info/WHEEL +4 -0
- python_mobius-0.1.0.dist-info/entry_points.txt +2 -0
- python_mobius-0.1.0.dist-info/licenses/LICENSE +338 -0
mobius/device.py
ADDED
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""
|
|
2
|
+
High-level async client for a single Mobius BLE device (pump, light, or
|
|
3
|
+
anything else sharing the protocol).
|
|
4
|
+
|
|
5
|
+
Uses `bleak` for BLE, and `bleak-retry-connector` if installed (recommended
|
|
6
|
+
-- same connection-retry approach Home Assistant's own Bluetooth integration
|
|
7
|
+
uses; falls back to a manual retry loop otherwise).
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import asyncio
|
|
13
|
+
import struct
|
|
14
|
+
from typing import Optional
|
|
15
|
+
|
|
16
|
+
from bleak import BleakClient, BleakScanner
|
|
17
|
+
from bleak.backends.device import BLEDevice
|
|
18
|
+
|
|
19
|
+
try:
|
|
20
|
+
from bleak_retry_connector import establish_connection
|
|
21
|
+
_HAVE_RETRY_CONNECTOR = True
|
|
22
|
+
except ImportError:
|
|
23
|
+
_HAVE_RETRY_CONNECTOR = False
|
|
24
|
+
|
|
25
|
+
from .frame import (
|
|
26
|
+
build_frame, parse_frame, ParsedFrame,
|
|
27
|
+
encode_get_attribute, encode_set_attribute, decode_attribute_response,
|
|
28
|
+
OPGROUP_C2CI_REQUEST, OPCODE_GET_ATTR, OPCODE_SET_ATTR, next_message_id,
|
|
29
|
+
)
|
|
30
|
+
from .constants import (
|
|
31
|
+
C2Attribute, PrimitiveType, Model, ErrorState, SceneID, OperationState,
|
|
32
|
+
FsciStatus, PhysicalValueID, VisualID,
|
|
33
|
+
PRIMITIVE_SIZE, LIGHT_PRIMITIVES, PUMP_PRIMITIVES_VERIFIED, PUMP_PRIMITIVES_EXPERIMENTAL,
|
|
34
|
+
)
|
|
35
|
+
from .schedule import (
|
|
36
|
+
SchedulePoint, interpolate_light_schedule,
|
|
37
|
+
PumpSchedulePoint, get_active_pump_block,
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
# --------------------------------------------------------------------------
|
|
41
|
+
# GATT UUIDs (service 01ff0100-...; four characteristics under it)
|
|
42
|
+
# --------------------------------------------------------------------------
|
|
43
|
+
|
|
44
|
+
SERVICE_GENERAL = "01ff0100-ba5e-f4ee-5ca1-eb1e5e4b1ce0"
|
|
45
|
+
CHAR_RX_DATA = "01ff0101-ba5e-f4ee-5ca1-eb1e5e4b1ce0" # notify: device -> app, fragment
|
|
46
|
+
CHAR_RX_FINAL = "01ff0102-ba5e-f4ee-5ca1-eb1e5e4b1ce0" # notify: device -> app, last fragment
|
|
47
|
+
CHAR_TX_DATA = "01ff0103-ba5e-f4ee-5ca1-eb1e5e4b1ce0" # write: app -> device, fragment
|
|
48
|
+
CHAR_TX_FINAL = "01ff0104-ba5e-f4ee-5ca1-eb1e5e4b1ce0" # write: app -> device, last fragment
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class MobiusDevice:
|
|
52
|
+
"""
|
|
53
|
+
High-level async client for a single Mobius-protocol BLE device.
|
|
54
|
+
|
|
55
|
+
Usage:
|
|
56
|
+
found = await scan_for_mobius_devices()
|
|
57
|
+
async with MobiusDevice(found[0]) as device:
|
|
58
|
+
summary = await device.get_device_summary()
|
|
59
|
+
"""
|
|
60
|
+
|
|
61
|
+
def __init__(self, device: "str | BLEDevice", mtu: int = 20, connect_timeout: float = 30.0,
|
|
62
|
+
adapter: Optional[str] = None):
|
|
63
|
+
# Accepting a BLEDevice (from an existing scan) avoids a second,
|
|
64
|
+
# independent discovery pass in connect() -- re-scanning per device
|
|
65
|
+
# can cause BlueZ to drop the device between "found" and "connect"
|
|
66
|
+
# ("device disappeared" errors), especially when connecting to
|
|
67
|
+
# several devices back-to-back.
|
|
68
|
+
#
|
|
69
|
+
# adapter: only relevant for the fallback path in connect() (bare
|
|
70
|
+
# address, no pre-scanned BLEDevice). If you already have a
|
|
71
|
+
# BLEDevice from scan_for_mobius_devices(adapter=...), that device
|
|
72
|
+
# remembers which adapter it was seen on and this argument is ignored.
|
|
73
|
+
self._device_or_address = device
|
|
74
|
+
self.address = device.address if isinstance(device, BLEDevice) else device
|
|
75
|
+
self.mtu = mtu # conservative default; bleak/OS negotiates the real MTU
|
|
76
|
+
self.connect_timeout = connect_timeout
|
|
77
|
+
self.adapter = adapter
|
|
78
|
+
self._client: Optional[BleakClient] = None
|
|
79
|
+
self._rx_buffer = bytearray()
|
|
80
|
+
self._pending: dict[int, asyncio.Future] = {}
|
|
81
|
+
|
|
82
|
+
async def __aenter__(self) -> "MobiusDevice":
|
|
83
|
+
await self.connect()
|
|
84
|
+
return self
|
|
85
|
+
|
|
86
|
+
async def __aexit__(self, *exc):
|
|
87
|
+
await self.disconnect()
|
|
88
|
+
|
|
89
|
+
async def connect(self):
|
|
90
|
+
if _HAVE_RETRY_CONNECTOR:
|
|
91
|
+
if isinstance(self._device_or_address, BLEDevice):
|
|
92
|
+
device = self._device_or_address
|
|
93
|
+
else:
|
|
94
|
+
# Fallback: only re-scan if we were only ever given a bare
|
|
95
|
+
# address (e.g. a hardcoded MAC from a previous identify pass).
|
|
96
|
+
find_kwargs = {"timeout": 10.0}
|
|
97
|
+
if self.adapter is not None:
|
|
98
|
+
find_kwargs["bluez"] = {"adapter": self.adapter}
|
|
99
|
+
device = await BleakScanner.find_device_by_address(self.address, **find_kwargs)
|
|
100
|
+
if device is None:
|
|
101
|
+
raise IOError(f"could not find BLE device {self.address} while scanning")
|
|
102
|
+
self._client = await establish_connection(
|
|
103
|
+
BleakClient, device, "mobius-device", timeout=self.connect_timeout
|
|
104
|
+
)
|
|
105
|
+
else:
|
|
106
|
+
self._client = BleakClient(self.address, timeout=self.connect_timeout)
|
|
107
|
+
for attempt in range(3):
|
|
108
|
+
try:
|
|
109
|
+
await self._client.connect()
|
|
110
|
+
break
|
|
111
|
+
except Exception:
|
|
112
|
+
if attempt == 2:
|
|
113
|
+
raise
|
|
114
|
+
await asyncio.sleep(2.0)
|
|
115
|
+
await self._client.start_notify(CHAR_RX_DATA, self._on_rx_data)
|
|
116
|
+
await self._client.start_notify(CHAR_RX_FINAL, self._on_rx_final)
|
|
117
|
+
|
|
118
|
+
async def disconnect(self):
|
|
119
|
+
if self._client and self._client.is_connected:
|
|
120
|
+
await self._client.disconnect()
|
|
121
|
+
|
|
122
|
+
def _on_rx_data(self, _handle, data: bytearray):
|
|
123
|
+
self._rx_buffer.extend(data)
|
|
124
|
+
|
|
125
|
+
def _on_rx_final(self, _handle, data: bytearray):
|
|
126
|
+
self._rx_buffer.extend(data)
|
|
127
|
+
raw = bytes(self._rx_buffer)
|
|
128
|
+
self._rx_buffer.clear()
|
|
129
|
+
frame = parse_frame(raw)
|
|
130
|
+
if frame is None:
|
|
131
|
+
return
|
|
132
|
+
fut = self._pending.pop(frame.message_id, None)
|
|
133
|
+
if fut and not fut.done():
|
|
134
|
+
fut.set_result(frame)
|
|
135
|
+
|
|
136
|
+
async def _send_frame(self, frame: bytes, message_id: int,
|
|
137
|
+
wait_response: bool = True, timeout: float = 5.0) -> Optional[ParsedFrame]:
|
|
138
|
+
assert self._client is not None
|
|
139
|
+
fut: asyncio.Future = asyncio.get_event_loop().create_future()
|
|
140
|
+
if wait_response:
|
|
141
|
+
self._pending[message_id] = fut
|
|
142
|
+
|
|
143
|
+
# Fragment to MTU; last chunk goes to *_FINAL and triggers processing device-side.
|
|
144
|
+
chunks = [frame[i:i + self.mtu] for i in range(0, len(frame), self.mtu)] or [b""]
|
|
145
|
+
for chunk in chunks[:-1]:
|
|
146
|
+
await self._client.write_gatt_char(CHAR_TX_DATA, chunk, response=False)
|
|
147
|
+
await self._client.write_gatt_char(CHAR_TX_FINAL, chunks[-1], response=False)
|
|
148
|
+
|
|
149
|
+
if not wait_response:
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
return await asyncio.wait_for(fut, timeout=timeout)
|
|
153
|
+
finally:
|
|
154
|
+
self._pending.pop(message_id, None)
|
|
155
|
+
|
|
156
|
+
async def get_attribute(self, attr_id: int, index: int = 0, count: int = 1) -> list[bytes]:
|
|
157
|
+
message_id = next_message_id()
|
|
158
|
+
payload = encode_get_attribute(attr_id, index, count, extended=False)
|
|
159
|
+
frame = build_frame(OPGROUP_C2CI_REQUEST, OPCODE_GET_ATTR, payload, message_id)
|
|
160
|
+
resp = await self._send_frame(frame, message_id)
|
|
161
|
+
if resp is None or not resp.crc_ok:
|
|
162
|
+
raise IOError("no/invalid response from device")
|
|
163
|
+
status = resp.data[0] if resp.data else FsciStatus.Failed
|
|
164
|
+
if status != FsciStatus.Success:
|
|
165
|
+
raise IOError(f"device returned FSCI status {status!r} for attribute {attr_id}")
|
|
166
|
+
attrs = decode_attribute_response(resp.data, extended=False)
|
|
167
|
+
for a in attrs:
|
|
168
|
+
if a.attr_id == attr_id:
|
|
169
|
+
return a.values
|
|
170
|
+
return []
|
|
171
|
+
|
|
172
|
+
async def set_attribute(self, attr_id: int, value: bytes, index: int = 0) -> None:
|
|
173
|
+
message_id = next_message_id()
|
|
174
|
+
payload = encode_set_attribute(attr_id, [value], index, extended=False)
|
|
175
|
+
frame = build_frame(OPGROUP_C2CI_REQUEST, OPCODE_SET_ATTR, payload, message_id)
|
|
176
|
+
resp = await self._send_frame(frame, message_id)
|
|
177
|
+
if resp is None or not resp.crc_ok:
|
|
178
|
+
raise IOError("no/invalid response from device")
|
|
179
|
+
status = resp.data[0] if resp.data else FsciStatus.Failed
|
|
180
|
+
if status != FsciStatus.Success:
|
|
181
|
+
raise IOError(f"device returned FSCI status {status!r} setting attribute {attr_id}")
|
|
182
|
+
|
|
183
|
+
# ---- scenes -----------------------------------------------------------
|
|
184
|
+
|
|
185
|
+
async def start_scene(self, scene_id: SceneID, duration_minutes: int = 0) -> None:
|
|
186
|
+
"""duration_minutes=0 lets the device use the scene's own configured duration."""
|
|
187
|
+
value = struct.pack("<HH", int(scene_id), duration_minutes)
|
|
188
|
+
await self.set_attribute(C2Attribute.CurrentScene, value)
|
|
189
|
+
|
|
190
|
+
async def start_feed_mode(self, duration_minutes: int = 0) -> None:
|
|
191
|
+
await self.start_scene(SceneID.FeedMode, duration_minutes)
|
|
192
|
+
|
|
193
|
+
async def resume_schedule(self) -> None:
|
|
194
|
+
"""Cancel whatever scene is running and go back to the normal schedule."""
|
|
195
|
+
await self.set_attribute(C2Attribute.OperationState, bytes([OperationState.Schedule]))
|
|
196
|
+
|
|
197
|
+
async def get_current_scene(self) -> tuple[SceneID, int]:
|
|
198
|
+
raw = await self.get_attribute(C2Attribute.CurrentScene)
|
|
199
|
+
scene_id, duration = struct.unpack("<HH", raw[0])
|
|
200
|
+
return SceneID(scene_id), duration
|
|
201
|
+
|
|
202
|
+
async def get_operation_state(self) -> OperationState:
|
|
203
|
+
raw = await self.get_attribute(C2Attribute.OperationState)
|
|
204
|
+
return OperationState(raw[0][0])
|
|
205
|
+
|
|
206
|
+
async def identify_device_type(self) -> PrimitiveType:
|
|
207
|
+
"""
|
|
208
|
+
All Mobius-protocol devices (pumps, lights, dosers...) share the
|
|
209
|
+
exact same BLE/FSCI wire protocol and advertise the same name, so
|
|
210
|
+
this is the reliable way to tell them apart over BLE without
|
|
211
|
+
hardcoding MAC addresses.
|
|
212
|
+
"""
|
|
213
|
+
raw = await self.get_attribute(C2Attribute.PrimitiveType)
|
|
214
|
+
return PrimitiveType(raw[0][0])
|
|
215
|
+
|
|
216
|
+
# ---- generic identity (mirrors BasicInfoProcess.setupRequest) --------
|
|
217
|
+
|
|
218
|
+
async def get_device_info(self) -> dict:
|
|
219
|
+
"""
|
|
220
|
+
Fields the app itself reads for ANY device before doing anything
|
|
221
|
+
type-specific: Model, Name, SerialNumber, PrimitiveType, ErrorState.
|
|
222
|
+
Also grabs MACAddress since it's cheap. FirmwareVersion/HardwareRevision
|
|
223
|
+
are skipped -- the app indexes them per-FirmwareType/HardwareInfo
|
|
224
|
+
sub-field, adding real complexity for modest payoff.
|
|
225
|
+
"""
|
|
226
|
+
async def _get(attr, default=None):
|
|
227
|
+
try:
|
|
228
|
+
raw = await self.get_attribute(attr)
|
|
229
|
+
return raw[0] if raw else default
|
|
230
|
+
except Exception:
|
|
231
|
+
return default
|
|
232
|
+
|
|
233
|
+
model_raw = await _get(C2Attribute.Model)
|
|
234
|
+
name_raw = await _get(C2Attribute.Name)
|
|
235
|
+
serial_raw = await _get(C2Attribute.SerialNumber)
|
|
236
|
+
primitive_raw = await _get(C2Attribute.PrimitiveType)
|
|
237
|
+
error_raw = await _get(C2Attribute.ErrorState)
|
|
238
|
+
mac_raw = await _get(C2Attribute.MACAddress)
|
|
239
|
+
|
|
240
|
+
model_val = struct.unpack("<h", model_raw)[0] if model_raw and len(model_raw) >= 2 else None
|
|
241
|
+
try:
|
|
242
|
+
model = Model(model_val) if model_val is not None else None
|
|
243
|
+
except ValueError:
|
|
244
|
+
model = None
|
|
245
|
+
|
|
246
|
+
error_val = struct.unpack("<h", error_raw)[0] if error_raw and len(error_raw) >= 2 else None
|
|
247
|
+
try:
|
|
248
|
+
error = ErrorState(error_val) if error_val is not None else None
|
|
249
|
+
except ValueError:
|
|
250
|
+
error = None
|
|
251
|
+
|
|
252
|
+
return {
|
|
253
|
+
"model_raw": model_val,
|
|
254
|
+
"model": model.name if model is not None else (f"unknown({model_val})" if model_val is not None else None),
|
|
255
|
+
"name": name_raw.decode("utf-8", errors="replace").rstrip("\x00") if name_raw else None,
|
|
256
|
+
"serial": serial_raw.hex() if serial_raw else None,
|
|
257
|
+
"primitive_type": PrimitiveType(primitive_raw[0]).name if primitive_raw else None,
|
|
258
|
+
"error_state": error.name if error is not None else (f"unknown({error_val})" if error_val is not None else None),
|
|
259
|
+
"mac_address": ":".join(f"{b:02X}" for b in mac_raw) if mac_raw else None,
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
async def get_device_summary(self) -> dict:
|
|
263
|
+
"""
|
|
264
|
+
The "read everything" entrypoint: generic identity info for any
|
|
265
|
+
device, plus type-specific telemetry dispatched by PrimitiveType.
|
|
266
|
+
|
|
267
|
+
Every result includes info["support"], one of:
|
|
268
|
+
"light" -- VisualV1, fully implemented and verified live
|
|
269
|
+
"pump" -- pump-class primitives, fully implemented and verified live
|
|
270
|
+
"pump (experimental)" -- CoffeeV1 (NYOS Quantum etc.): same primitive
|
|
271
|
+
size/structure as pump devices, so the pump
|
|
272
|
+
parser is applied, but this has never been
|
|
273
|
+
checked against real Coffee/Quantum hardware
|
|
274
|
+
"unsupported" -- known primitive type, no parser implemented;
|
|
275
|
+
identity info only, plus the primitive's
|
|
276
|
+
byte size as a starting point
|
|
277
|
+
"""
|
|
278
|
+
info = await self.get_device_info()
|
|
279
|
+
primitive_name = info.get("primitive_type")
|
|
280
|
+
try:
|
|
281
|
+
primitive = PrimitiveType[primitive_name] if primitive_name else None
|
|
282
|
+
except KeyError:
|
|
283
|
+
primitive = None
|
|
284
|
+
|
|
285
|
+
if primitive in LIGHT_PRIMITIVES:
|
|
286
|
+
info["support"] = "light"
|
|
287
|
+
info["channels"] = [c.name for c in await self.get_supported_channels()]
|
|
288
|
+
points = await self.get_light_schedule(which=1)
|
|
289
|
+
info["schedule_point_count"] = len(points)
|
|
290
|
+
current = await self.get_current_light_intensities(which=1)
|
|
291
|
+
info["current_intensities"] = {ch.name: v for ch, v in current.items()}
|
|
292
|
+
|
|
293
|
+
elif primitive in PUMP_PRIMITIVES_VERIFIED or primitive in PUMP_PRIMITIVES_EXPERIMENTAL:
|
|
294
|
+
info["support"] = "pump" if primitive in PUMP_PRIMITIVES_VERIFIED else "pump (experimental)"
|
|
295
|
+
info["telemetry"] = await self.get_pump_telemetry()
|
|
296
|
+
info["operation_state"] = (await self.get_operation_state()).name
|
|
297
|
+
try:
|
|
298
|
+
points = await self.get_pump_schedule(which=1)
|
|
299
|
+
info["schedule_point_count"] = len(points)
|
|
300
|
+
import datetime
|
|
301
|
+
now = datetime.datetime.now()
|
|
302
|
+
block = get_active_pump_block(points, now.hour * 60 + now.minute)
|
|
303
|
+
if block:
|
|
304
|
+
info["current_pump_mode"] = block.pump.mode.name
|
|
305
|
+
info["current_pump_params"] = {
|
|
306
|
+
p.name: (v.hex() if isinstance(v, bytes) else (v.name if hasattr(v, "name") else v))
|
|
307
|
+
for p, v in block.pump.params.items()
|
|
308
|
+
}
|
|
309
|
+
except Exception as e:
|
|
310
|
+
info["schedule_error"] = str(e)
|
|
311
|
+
|
|
312
|
+
else:
|
|
313
|
+
info["support"] = "unsupported"
|
|
314
|
+
size = PRIMITIVE_SIZE.get(primitive) if primitive else None
|
|
315
|
+
info["support_note"] = (
|
|
316
|
+
f"PrimitiveType {primitive_name!r} has no parser implemented here. "
|
|
317
|
+
f"Identity info only (model/name/serial/error_state above). "
|
|
318
|
+
+ (f"Its schedule-point primitive is {size} bytes, per the protocol, "
|
|
319
|
+
f"if you want to add support." if size is not None else "")
|
|
320
|
+
)
|
|
321
|
+
|
|
322
|
+
return info
|
|
323
|
+
|
|
324
|
+
# ---- pump telemetry (mirrors FlowRange.setupRequest) ------------------
|
|
325
|
+
|
|
326
|
+
async def get_pump_telemetry(self) -> dict:
|
|
327
|
+
"""
|
|
328
|
+
Returns {"speed": int, "gph": int}. Both are genuinely read live from
|
|
329
|
+
the device -- confirmed via FlowRange.java, which is what the app's
|
|
330
|
+
dashboard actually queries for the pump gauge widget.
|
|
331
|
+
|
|
332
|
+
"gph" comes from PhysicalValues/GallonsPerHour (int32 LE).
|
|
333
|
+
"speed" comes from MotorSpeed directly (int16).
|
|
334
|
+
MotorRPM is defined in the protocol but never queried anywhere in the
|
|
335
|
+
app -- not included here since it's unconfirmed whether firmware
|
|
336
|
+
actually populates it.
|
|
337
|
+
"""
|
|
338
|
+
speed_raw = await self.get_attribute(C2Attribute.MotorSpeed)
|
|
339
|
+
gph_raw = await self.get_attribute(
|
|
340
|
+
C2Attribute.PhysicalValues, index=int(PhysicalValueID.GallonsPerHour), count=1
|
|
341
|
+
)
|
|
342
|
+
speed = struct.unpack("<h", speed_raw[0])[0] if speed_raw else None
|
|
343
|
+
gph = struct.unpack("<i", gph_raw[0])[0] if gph_raw else None
|
|
344
|
+
return {"speed": speed, "gph": gph}
|
|
345
|
+
|
|
346
|
+
# ---- light schedule (Radion/XR15 etc.) --------------------------------
|
|
347
|
+
|
|
348
|
+
async def get_supported_channels(self) -> list[VisualID]:
|
|
349
|
+
"""Confirmed via Visuals.java: the static list of channels this light has."""
|
|
350
|
+
raw = await self.get_attribute(C2Attribute.SupportedColorChannels, index=0, count=0xFFFF)
|
|
351
|
+
out = []
|
|
352
|
+
for v in raw:
|
|
353
|
+
if v:
|
|
354
|
+
try:
|
|
355
|
+
out.append(VisualID(v[0]))
|
|
356
|
+
except ValueError:
|
|
357
|
+
pass
|
|
358
|
+
return out
|
|
359
|
+
|
|
360
|
+
async def get_light_schedule(self, which: int = 1) -> list[SchedulePoint]:
|
|
361
|
+
"""
|
|
362
|
+
Fetches the raw programmed point-schedule (which=1 or 2, for
|
|
363
|
+
Schedule1/Schedule2). Mirrors Schedule.setupRequest()'s
|
|
364
|
+
addAttribute(Schedule1, index=0, count=0xFFFF) call.
|
|
365
|
+
|
|
366
|
+
NOTE: the response parser assumes every returned point element is the
|
|
367
|
+
same byte length (see FsciConfirmResponse's fixed count*length
|
|
368
|
+
layout). This holds if every point on the device always encodes the
|
|
369
|
+
full supported-channel set; unconfirmed for schedules with points
|
|
370
|
+
that were only ever edited to include a subset of channels.
|
|
371
|
+
"""
|
|
372
|
+
attr = C2Attribute.Schedule1 if which == 1 else C2Attribute.Schedule2
|
|
373
|
+
raw = await self.get_attribute(attr, index=0, count=0xFFFF)
|
|
374
|
+
points = []
|
|
375
|
+
for element in raw:
|
|
376
|
+
pt = SchedulePoint.parse(element)
|
|
377
|
+
if pt is not None:
|
|
378
|
+
points.append(pt)
|
|
379
|
+
return points
|
|
380
|
+
|
|
381
|
+
async def get_current_light_intensities(self, which: int = 1,
|
|
382
|
+
minute_of_day: Optional[int] = None) -> dict:
|
|
383
|
+
"""
|
|
384
|
+
Replicates what the app actually shows as "current intensity": fetch
|
|
385
|
+
the schedule, then linearly interpolate it for the given time
|
|
386
|
+
(defaults to local system time -- NOT read from the device; the app
|
|
387
|
+
itself uses the tank's configured timezone against phone-local time,
|
|
388
|
+
not a device clock read, so this mirrors that behavior).
|
|
389
|
+
|
|
390
|
+
Returns {VisualID: intensity_float_permille}. Divide by 10 for percent.
|
|
391
|
+
"""
|
|
392
|
+
if minute_of_day is None:
|
|
393
|
+
import datetime
|
|
394
|
+
now = datetime.datetime.now()
|
|
395
|
+
minute_of_day = now.hour * 60 + now.minute
|
|
396
|
+
points = await self.get_light_schedule(which)
|
|
397
|
+
return interpolate_light_schedule(points, minute_of_day)
|
|
398
|
+
|
|
399
|
+
# ---- pump schedule (VorTech/Nero/Vectra/Alpaca/Turtle) -----------------
|
|
400
|
+
|
|
401
|
+
async def get_pump_schedule(self, which: int = 1) -> list[PumpSchedulePoint]:
|
|
402
|
+
"""Same Schedule1/Schedule2 attribute as lights, decoded as PumpPrimitive."""
|
|
403
|
+
attr = C2Attribute.Schedule1 if which == 1 else C2Attribute.Schedule2
|
|
404
|
+
raw = await self.get_attribute(attr, index=0, count=0xFFFF)
|
|
405
|
+
points = []
|
|
406
|
+
for element in raw:
|
|
407
|
+
pt = PumpSchedulePoint.parse(element)
|
|
408
|
+
if pt is not None:
|
|
409
|
+
points.append(pt)
|
|
410
|
+
return points
|
|
411
|
+
|
|
412
|
+
async def get_current_pump_block(self, which: int = 1,
|
|
413
|
+
minute_of_day: Optional[int] = None) -> Optional[PumpSchedulePoint]:
|
|
414
|
+
"""
|
|
415
|
+
Returns the PumpSchedulePoint currently active (block lookup, not
|
|
416
|
+
interpolation -- see get_active_pump_block()). Defaults to local
|
|
417
|
+
system time, same rationale as get_current_light_intensities().
|
|
418
|
+
"""
|
|
419
|
+
if minute_of_day is None:
|
|
420
|
+
import datetime
|
|
421
|
+
now = datetime.datetime.now()
|
|
422
|
+
minute_of_day = now.hour * 60 + now.minute
|
|
423
|
+
points = await self.get_pump_schedule(which)
|
|
424
|
+
return get_active_pump_block(points, minute_of_day)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
# Backwards-compatible alias (this class was originally MobiusPump before the
|
|
428
|
+
# library grew to cover lights, dosers, etc. too).
|
|
429
|
+
MobiusPump = MobiusDevice
|
mobius/discovery.py
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""
|
|
2
|
+
BLE discovery helpers: finding Mobius devices, and reading model/serial/
|
|
3
|
+
pan_id straight from their advertisements with no GATT connection required.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import asyncio
|
|
9
|
+
from typing import Optional
|
|
10
|
+
|
|
11
|
+
from bleak import BleakScanner
|
|
12
|
+
from bleak.backends.device import BLEDevice
|
|
13
|
+
|
|
14
|
+
from .manufacturer import MOBIUS_COMPANY_ID, MobiusAdvertisement, parse_manufacturer_data
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
async def scan_for_mobius_devices(timeout: float = 15.0, adapter: Optional[str] = None) -> list[BLEDevice]:
|
|
18
|
+
"""
|
|
19
|
+
adapter: Linux/BlueZ only, e.g. "hci0" or "hci1". Devices returned carry
|
|
20
|
+
the adapter they were seen on, and MobiusDevice reuses that same adapter
|
|
21
|
+
for connecting -- so set it here, not on MobiusDevice.
|
|
22
|
+
"""
|
|
23
|
+
kwargs = {"timeout": timeout}
|
|
24
|
+
if adapter is not None:
|
|
25
|
+
kwargs["bluez"] = {"adapter": adapter}
|
|
26
|
+
devices = await BleakScanner.discover(**kwargs)
|
|
27
|
+
return [d for d in devices if d.name and "mobius" in d.name.lower()]
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
async def scan_for_mobius_devices_with_info(
|
|
31
|
+
timeout: float = 15.0, adapter: Optional[str] = None
|
|
32
|
+
) -> list[tuple[BLEDevice, Optional[MobiusAdvertisement]]]:
|
|
33
|
+
"""
|
|
34
|
+
Like scan_for_mobius_devices(), but also parses manufacturer data --
|
|
35
|
+
giving you model, serial, and pan_id (tank/mesh grouping) for every
|
|
36
|
+
device found, with NO GATT connection required at all.
|
|
37
|
+
"""
|
|
38
|
+
found: dict = {}
|
|
39
|
+
|
|
40
|
+
def _cb(device, adv):
|
|
41
|
+
if device.name and "mobius" in device.name.lower():
|
|
42
|
+
payload = adv.manufacturer_data.get(MOBIUS_COMPANY_ID)
|
|
43
|
+
info = parse_manufacturer_data(payload) if payload else None
|
|
44
|
+
found[device.address] = (device, info)
|
|
45
|
+
|
|
46
|
+
kwargs = {"detection_callback": _cb}
|
|
47
|
+
if adapter is not None:
|
|
48
|
+
kwargs["bluez"] = {"adapter": adapter}
|
|
49
|
+
scanner = BleakScanner(**kwargs)
|
|
50
|
+
await scanner.start()
|
|
51
|
+
await asyncio.sleep(timeout)
|
|
52
|
+
await scanner.stop()
|
|
53
|
+
return list(found.values())
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def group_by_pan_id(
|
|
57
|
+
devices: list[tuple[BLEDevice, Optional[MobiusAdvertisement]]]
|
|
58
|
+
) -> dict:
|
|
59
|
+
"""Groups scan_for_mobius_devices_with_info() results by pan_id (tank/mesh)."""
|
|
60
|
+
groups: dict = {}
|
|
61
|
+
for device, info in devices:
|
|
62
|
+
key = info.pan_id if info else None
|
|
63
|
+
groups.setdefault(key, []).append((device, info))
|
|
64
|
+
return groups
|
mobius/frame.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Wire framing for the FSCI/C2CI protocol: the byte layout every message
|
|
3
|
+
shares, and the Get/Set-attribute payload structure layered on top of it.
|
|
4
|
+
|
|
5
|
+
See documentation/02-framing-and-crc.md and documentation/03-attributes-and-opcodes.md
|
|
6
|
+
for the full derivation and confirmation evidence.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import struct
|
|
12
|
+
from dataclasses import dataclass
|
|
13
|
+
from typing import Optional
|
|
14
|
+
|
|
15
|
+
from .crc import crc16
|
|
16
|
+
|
|
17
|
+
# --------------------------------------------------------------------------
|
|
18
|
+
# opGroup / opCode constants
|
|
19
|
+
# --------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
OPGROUP_C2CI_REQUEST = 0xDE
|
|
22
|
+
OPGROUP_C2CI_CONFIRM = 0xDF
|
|
23
|
+
OPGROUP_FSCI_REQUEST = 0xCE
|
|
24
|
+
OPGROUP_FSCI_CONFIRM = 0xCF
|
|
25
|
+
OPGROUP_QOTAP_REQUEST = 0xBE
|
|
26
|
+
OPGROUP_QOTAP_CONFIRM = 0xBF
|
|
27
|
+
# Java's constants are signed bytes:
|
|
28
|
+
# C2CI_Request = -34 = 0xDE, C2CI_Confirm = -33 = 0xDF
|
|
29
|
+
# FSCI_Request = -50 = 0xCE, FSCI_Confirm = -49 = 0xCF
|
|
30
|
+
# QOTAP_Request = -66 = 0xBE, QOTAP_Confirm = -65 = 0xBF
|
|
31
|
+
|
|
32
|
+
OPCODE_GET_ATTR = 0x17 # 23 - old/short variant (1-byte index/count/length)
|
|
33
|
+
OPCODE_GET_ATTR_EXT = 0x1C # 28 - new/long variant (2-byte fields)
|
|
34
|
+
OPCODE_SET_ATTR = 0x18 # 24 - old/short variant
|
|
35
|
+
OPCODE_SET_ATTR_EXT = 0x1D # 29 - new/long variant
|
|
36
|
+
OPCODE_GET_ATTR_ELEMENTS = 0x20 # 32
|
|
37
|
+
OPCODE_GET_ATTR_ELEMENTS_EXT = 0x25 # 37
|
|
38
|
+
OPCODE_GET_OPCODES = 0x29 # 41
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def make_reserved(fsci_instance: int = 0, group: int = 0,
|
|
42
|
+
do_not_respond: bool = False, superuser: bool = False) -> bytes:
|
|
43
|
+
"""Builds the 2-byte 'reserved' frame field. Bit layout confirmed from Request.java:
|
|
44
|
+
byte0 bits0-1=fsci_instance, bits2-7=group<<2; byte1 bit0=doNotRespond, bit1=superuser."""
|
|
45
|
+
b0 = (fsci_instance & 0x03) | ((group & 0x3F) << 2)
|
|
46
|
+
b1 = (1 if do_not_respond else 0) | (2 if superuser else 0)
|
|
47
|
+
return bytes([b0, b1])
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
_next_message_id = 0
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def next_message_id() -> int:
|
|
54
|
+
"""Simple monotonically-increasing message ID generator (module-global)."""
|
|
55
|
+
global _next_message_id
|
|
56
|
+
_next_message_id = (_next_message_id + 1) % 20000
|
|
57
|
+
return _next_message_id
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def build_frame(op_group: int, op_code: int, data: bytes,
|
|
61
|
+
message_id: Optional[int] = None,
|
|
62
|
+
reserved: bytes = b"\x00\x00") -> bytes:
|
|
63
|
+
"""
|
|
64
|
+
Builds a full wire frame:
|
|
65
|
+
0x02 | opGroup | opCode | msgId(u16 LE) | reserved(2) | len(u16 LE) | data | crc16(u16 LE)
|
|
66
|
+
CRC is computed over everything from opGroup through data (i.e. bytes[1:] before the CRC).
|
|
67
|
+
"""
|
|
68
|
+
if message_id is None:
|
|
69
|
+
message_id = next_message_id()
|
|
70
|
+
body = struct.pack("<BBH2sH", op_group, op_code, message_id, reserved, len(data)) + data
|
|
71
|
+
crc = crc16(body)
|
|
72
|
+
return b"\x02" + body + struct.pack("<H", crc)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass
|
|
76
|
+
class ParsedFrame:
|
|
77
|
+
op_group: int
|
|
78
|
+
op_code: int
|
|
79
|
+
message_id: int
|
|
80
|
+
reserved: bytes
|
|
81
|
+
data: bytes
|
|
82
|
+
crc_ok: bool
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def parse_frame(raw: bytes) -> Optional[ParsedFrame]:
|
|
86
|
+
"""Parses and CRC-validates a full wire frame. Mirrors FsciHelper.isValidMessage + Response.create."""
|
|
87
|
+
if len(raw) <= 8 or raw[0] != 0x02:
|
|
88
|
+
return None
|
|
89
|
+
data_len = struct.unpack_from("<H", raw, 7)[0]
|
|
90
|
+
total_len = data_len + 11
|
|
91
|
+
if len(raw) < total_len:
|
|
92
|
+
return None
|
|
93
|
+
body_end = data_len + 9
|
|
94
|
+
computed = crc16(raw[1:body_end])
|
|
95
|
+
received = struct.unpack_from("<H", raw, body_end)[0]
|
|
96
|
+
crc_ok = computed == received
|
|
97
|
+
op_group = raw[1]
|
|
98
|
+
op_code = raw[2]
|
|
99
|
+
message_id = struct.unpack_from("<H", raw, 3)[0]
|
|
100
|
+
reserved = raw[5:7]
|
|
101
|
+
data = raw[9:body_end]
|
|
102
|
+
return ParsedFrame(op_group, op_code, message_id, reserved, data, crc_ok)
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
# --------------------------------------------------------------------------
|
|
106
|
+
# Get / Set attribute payload (en/de)coding
|
|
107
|
+
# --------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
def encode_get_attribute(attr_id: int, index: int = 0, count: int = 1,
|
|
110
|
+
extended: bool = False) -> bytes:
|
|
111
|
+
"""Payload for a Get-attribute request: attrId(u16) + index + count (1 or 2 bytes each)."""
|
|
112
|
+
if extended:
|
|
113
|
+
return struct.pack("<HHH", attr_id, index, count)
|
|
114
|
+
return struct.pack("<H", attr_id) + bytes([index & 0xFF, count & 0xFF])
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def encode_set_attribute(attr_id: int, values: list[bytes], index: int = 0,
|
|
118
|
+
extended: bool = False) -> bytes:
|
|
119
|
+
"""Payload for a Set-attribute request. `values` is a list of same-shaped
|
|
120
|
+
byte strings (usually length 1): attrId + index + count + length + data."""
|
|
121
|
+
length = max(len(v) for v in values)
|
|
122
|
+
padded = [v + bytes(length - len(v)) for v in values]
|
|
123
|
+
if extended:
|
|
124
|
+
header = struct.pack("<HHHH", attr_id, index, len(values), length)
|
|
125
|
+
else:
|
|
126
|
+
header = struct.pack("<H", attr_id) + bytes([index & 0xFF, len(values) & 0xFF, length & 0xFF])
|
|
127
|
+
return header + b"".join(padded)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
@dataclass
|
|
131
|
+
class AttributeValue:
|
|
132
|
+
attr_id: int
|
|
133
|
+
index: int
|
|
134
|
+
values: list[bytes]
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def decode_attribute_response(data: bytes, extended: bool = False) -> list[AttributeValue]:
|
|
138
|
+
"""
|
|
139
|
+
Parses the repeated
|
|
140
|
+
attrId(u16) + index(u8/u16) + count(u8/u16) + length(u8/u16) + count*length bytes
|
|
141
|
+
structure used by Get-attribute confirm responses. `data` is the FSCI
|
|
142
|
+
confirm payload *after* the leading status byte (data[0]).
|
|
143
|
+
"""
|
|
144
|
+
out: list[AttributeValue] = []
|
|
145
|
+
pos = 1 # skip status byte
|
|
146
|
+
field = 2 if extended else 1
|
|
147
|
+
while pos < len(data):
|
|
148
|
+
attr_id = struct.unpack_from("<H", data, pos)[0]
|
|
149
|
+
pos += 2
|
|
150
|
+
if extended:
|
|
151
|
+
index = struct.unpack_from("<H", data, pos)[0]
|
|
152
|
+
else:
|
|
153
|
+
index = data[pos]
|
|
154
|
+
pos += field
|
|
155
|
+
if extended:
|
|
156
|
+
count = struct.unpack_from("<H", data, pos)[0]
|
|
157
|
+
else:
|
|
158
|
+
count = data[pos]
|
|
159
|
+
pos += field
|
|
160
|
+
if extended:
|
|
161
|
+
length = struct.unpack_from("<H", data, pos)[0]
|
|
162
|
+
else:
|
|
163
|
+
length = data[pos]
|
|
164
|
+
pos += field
|
|
165
|
+
values = []
|
|
166
|
+
for _ in range(count):
|
|
167
|
+
values.append(data[pos:pos + length])
|
|
168
|
+
pos += length
|
|
169
|
+
out.append(AttributeValue(attr_id, index, values))
|
|
170
|
+
return out
|