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/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