python-mobius 0.1.0__py3-none-any.whl → 0.1.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
mobius/__init__.py CHANGED
@@ -23,7 +23,8 @@ from .frame import (
23
23
  from .constants import (
24
24
  PrimitiveType, PRIMITIVE_SIZE, LIGHT_PRIMITIVES,
25
25
  PUMP_PRIMITIVES_VERIFIED, PUMP_PRIMITIVES_EXPERIMENTAL,
26
- Model, ErrorState, C2Attribute, PhysicalValueID, VisualID,
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.0"
44
+ __version__ = "0.1.2"
44
45
 
45
46
  __all__ = [
46
47
  "__version__",
@@ -57,7 +58,8 @@ __all__ = [
57
58
  # constants
58
59
  "PrimitiveType", "PRIMITIVE_SIZE", "LIGHT_PRIMITIVES",
59
60
  "PUMP_PRIMITIVES_VERIFIED", "PUMP_PRIMITIVES_EXPERIMENTAL",
60
- "Model", "ErrorState", "C2Attribute", "PhysicalValueID", "VisualID",
61
+ "Model", "MODEL_MANUFACTURER", "manufacturer_for_model",
62
+ "ErrorState", "C2Attribute", "PhysicalValueID", "VisualID",
61
63
  "SceneID", "OperationState", "FsciStatus",
62
64
  "PumpMode", "RampType", "PumpParam", "PUMP_PARAM_SIZE", "PUMP_MODE_PARAMS",
63
65
  # schedule
mobius/constants.py CHANGED
@@ -11,6 +11,7 @@ See documentation/ for the full derivation of each table.
11
11
  from __future__ import annotations
12
12
 
13
13
  from enum import IntEnum
14
+ from typing import Optional
14
15
 
15
16
 
16
17
  # --------------------------------------------------------------------------
@@ -107,6 +108,65 @@ class Model(IntEnum):
107
108
  Coffee2 = 397 # "Quantum 220/300" (NYOS)
108
109
 
109
110
 
111
+ # Ported from M.Model.getManufacturer(). The decompiled version branches on
112
+ # enum *ordinal* against a hardcoded position list (the full ~100-entry
113
+ # Model enum's index 1-37 range, plus a few extras, all map to "EcoTech
114
+ # Marine"); since this library only implements a curated subset of Model,
115
+ # this is re-derived by name pattern against that same logic instead of by
116
+ # ordinal position -- functionally equivalent for every Model value defined
117
+ # above, but if you add more Model entries, add their manufacturer here too.
118
+ MODEL_MANUFACTURER: dict[Model, str] = {
119
+ Model.Unknown: "Unknown",
120
+ Model.VorTechMP10wES: "EcoTech Marine",
121
+ Model.VorTechMP10wQD: "EcoTech Marine",
122
+ Model.VorTechMP40wES: "EcoTech Marine",
123
+ Model.VorTechMP40wQD: "EcoTech Marine",
124
+ Model.VorTechMP40wG3QD: "EcoTech Marine",
125
+ Model.VorTechMP60wES: "EcoTech Marine",
126
+ Model.VorTechMP60wQD: "EcoTech Marine",
127
+ Model.RadionXR30w: "EcoTech Marine",
128
+ Model.RadionXR30wG2: "EcoTech Marine",
129
+ Model.RadionXR30wPro: "EcoTech Marine",
130
+ Model.RadionXR30wG3: "EcoTech Marine",
131
+ Model.RadionXR30wG3Pro: "EcoTech Marine",
132
+ Model.RadionXR30wG4: "EcoTech Marine",
133
+ Model.RadionXR30wG4Pro: "EcoTech Marine",
134
+ Model.RadionXR30wFWPro: "EcoTech Marine",
135
+ Model.RadionXR30wPrototype: "EcoTech Marine",
136
+ Model.RadionXR15wG3Pro: "EcoTech Marine",
137
+ Model.RadionXR15wFW: "EcoTech Marine",
138
+ Model.RadionXR15wG4Pro: "EcoTech Marine",
139
+ Model.RadionXR15wFWPro: "EcoTech Marine",
140
+ Model.RadionXR15wPrototype: "EcoTech Marine",
141
+ Model.RadionXR15G5Pro: "EcoTech Marine",
142
+ Model.RadionXR15wG5Blue: "EcoTech Marine",
143
+ Model.RadionXR15wG5FW: "EcoTech Marine",
144
+ Model.RadionXR15wG6Pro: "EcoTech Marine",
145
+ Model.RadionXR15wG6Blue: "EcoTech Marine",
146
+ Model.RadionXR30wG5Pro: "EcoTech Marine",
147
+ Model.RadionXR30wG5Blue: "EcoTech Marine",
148
+ Model.VectraM1: "EcoTech Marine",
149
+ Model.VectraL1: "EcoTech Marine",
150
+ Model.VectraS1: "EcoTech Marine",
151
+ Model.VectraS2: "EcoTech Marine",
152
+ Model.VectraM2: "EcoTech Marine",
153
+ Model.VectraL2: "EcoTech Marine",
154
+ Model.Nero5: "AquaIllumination",
155
+ Model.Nero3: "AquaIllumination",
156
+ Model.DosingPump: "AquaIllumination",
157
+ Model.Muffler: "Neptune Systems", # confirmed explicitly in the decompile's manufacturer list
158
+ Model.Coffee1: "NYOS", # confirmed explicitly (CoffeeV1 branch)
159
+ Model.Coffee2: "NYOS",
160
+ }
161
+
162
+
163
+ def manufacturer_for_model(model: Optional[Model]) -> Optional[str]:
164
+ """Returns the manufacturer name for a Model, or None if unknown/unmapped."""
165
+ if model is None:
166
+ return None
167
+ return MODEL_MANUFACTURER.get(model)
168
+
169
+
110
170
  class ErrorState(IntEnum):
111
171
  """Confirmed literal values from M.ErrorState (subset covering common
112
172
  pump/light fault codes -- the full enum also has dosing-pump-specific codes)."""
mobius/device.py CHANGED
@@ -29,7 +29,7 @@ from .frame import (
29
29
  )
30
30
  from .constants import (
31
31
  C2Attribute, PrimitiveType, Model, ErrorState, SceneID, OperationState,
32
- FsciStatus, PhysicalValueID, VisualID,
32
+ FsciStatus, PhysicalValueID, VisualID, manufacturer_for_model,
33
33
  PRIMITIVE_SIZE, LIGHT_PRIMITIVES, PUMP_PRIMITIVES_VERIFIED, PUMP_PRIMITIVES_EXPERIMENTAL,
34
34
  )
35
35
  from .schedule import (
@@ -48,6 +48,25 @@ CHAR_TX_DATA = "01ff0103-ba5e-f4ee-5ca1-eb1e5e4b1ce0" # write: app -> device,
48
48
  CHAR_TX_FINAL = "01ff0104-ba5e-f4ee-5ca1-eb1e5e4b1ce0" # write: app -> device, last fragment
49
49
 
50
50
 
51
+ def _decode_serial(raw: bytes) -> str:
52
+ """
53
+ Serial numbers are printable ASCII on every device tested (confirmed by
54
+ cross-validating the GATT-read serial against the same serial parsed
55
+ from BLE advertisement manufacturer data -- see manufacturer.py and
56
+ documentation/08-manufacturer-data.md), so decode as text rather than
57
+ hex-encoding raw bytes. Falls back to hex for any non-printable-ASCII
58
+ edge case not yet observed, rather than raising.
59
+ """
60
+ text = raw.rstrip(b"\x00")
61
+ try:
62
+ decoded = text.decode("ascii")
63
+ except UnicodeDecodeError:
64
+ return raw.hex()
65
+ if decoded and all(32 <= ord(c) < 127 for c in decoded):
66
+ return decoded
67
+ return raw.hex()
68
+
69
+
51
70
  class MobiusDevice:
52
71
  """
53
72
  High-level async client for a single Mobius-protocol BLE device.
@@ -112,8 +131,29 @@ class MobiusDevice:
112
131
  if attempt == 2:
113
132
  raise
114
133
  await asyncio.sleep(2.0)
115
- 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)
134
+
135
+ # IMPORTANT: from here on, self._client holds a real, already-open
136
+ # BLE connection. If anything below fails, connect() (== __aenter__)
137
+ # raises -- and per the async context manager protocol, a raising
138
+ # __aenter__ means __aexit__/disconnect() is NEVER called by the
139
+ # caller's `async with` block. Without this try/except, a failure
140
+ # here would leak an orphaned open connection on the adapter every
141
+ # single time it happens -- and since coordinators retry on a timer,
142
+ # that leak compounds over repeated failures until the adapter's
143
+ # real connection-slot pool is exhausted, independent of range or
144
+ # any other integration. Confirmed as the likely cause of "no
145
+ # available connection slot" errors appearing over time rather than
146
+ # immediately, reported against real hardware.
147
+ try:
148
+ await self._client.start_notify(CHAR_RX_DATA, self._on_rx_data)
149
+ await self._client.start_notify(CHAR_RX_FINAL, self._on_rx_final)
150
+ except Exception:
151
+ try:
152
+ if self._client.is_connected:
153
+ await self._client.disconnect()
154
+ except Exception:
155
+ pass # best-effort cleanup; the original error is what matters
156
+ raise
117
157
 
118
158
  async def disconnect(self):
119
159
  if self._client and self._client.is_connected:
@@ -252,8 +292,9 @@ class MobiusDevice:
252
292
  return {
253
293
  "model_raw": model_val,
254
294
  "model": model.name if model is not None else (f"unknown({model_val})" if model_val is not None else None),
295
+ "manufacturer": manufacturer_for_model(model),
255
296
  "name": name_raw.decode("utf-8", errors="replace").rstrip("\x00") if name_raw else None,
256
- "serial": serial_raw.hex() if serial_raw else None,
297
+ "serial": _decode_serial(serial_raw) if serial_raw else None,
257
298
  "primitive_type": PrimitiveType(primitive_raw[0]).name if primitive_raw else None,
258
299
  "error_state": error.name if error is not None else (f"unknown({error_val})" if error_val is not None else None),
259
300
  "mac_address": ":".join(f"{b:02X}" for b in mac_raw) if mac_raw else None,
@@ -325,12 +366,24 @@ class MobiusDevice:
325
366
 
326
367
  async def get_pump_telemetry(self) -> dict:
327
368
  """
328
- Returns {"speed": int, "gph": int}. 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.
369
+ Returns {"speed": int, "speed_percent": float, "gph": int}. speed
370
+ and gph are genuinely read live from the device -- confirmed via
371
+ FlowRange.java, which is what the app's dashboard actually queries
372
+ for the pump gauge widget.
331
373
 
332
374
  "gph" comes from PhysicalValues/GallonsPerHour (int32 LE).
333
- "speed" comes from MotorSpeed directly (int16).
375
+
376
+ "speed" comes from MotorSpeed directly (int16). CONFIRMED (not
377
+ inferred) to be a percentage of max pump power in tenths of a
378
+ percent, not RPM: the app's own display code does
379
+ `String.format("%.0f%%", Math.abs(currentSpeed / 10.0f))` on this
380
+ exact value (FlowFragment.java/WidgetViewCell.java). "speed_percent"
381
+ is that same abs(speed)/10 conversion, provided as a convenience --
382
+ the sign of the raw "speed" value encodes rotation direction
383
+ (reverse), matching the same convention as the schedule's MaxSpeed
384
+ parameter (see PumpPrimitiveValue), so check the sign of "speed"
385
+ yourself if direction matters; speed_percent is always non-negative.
386
+
334
387
  MotorRPM is defined in the protocol but never queried anywhere in the
335
388
  app -- not included here since it's unconfirmed whether firmware
336
389
  actually populates it.
@@ -341,7 +394,8 @@ class MobiusDevice:
341
394
  )
342
395
  speed = struct.unpack("<h", speed_raw[0])[0] if speed_raw else None
343
396
  gph = struct.unpack("<i", gph_raw[0])[0] if gph_raw else None
344
- return {"speed": speed, "gph": gph}
397
+ speed_percent = abs(speed) / 10 if speed is not None else None
398
+ return {"speed": speed, "speed_percent": speed_percent, "gph": gph}
345
399
 
346
400
  # ---- light schedule (Radion/XR15 etc.) --------------------------------
347
401
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mobius
3
- Version: 0.1.0
3
+ Version: 0.1.2
4
4
  Summary: Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)
5
5
  Project-URL: Homepage, https://code.r3pek.org/r3pek/python-mobius
6
6
  Project-URL: Documentation, https://code.r3pek.org/r3pek/python-mobius/src/branch/main/documentation
@@ -24,6 +24,7 @@ Requires-Python: >=3.9
24
24
  Requires-Dist: bleak>=0.21
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: bleak-retry-connector>=3.0; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
28
  Requires-Dist: pytest>=7.0; extra == 'dev'
28
29
  Provides-Extra: retry
29
30
  Requires-Dist: bleak-retry-connector>=3.0; extra == 'retry'
@@ -0,0 +1,14 @@
1
+ mobius/__init__.py,sha256=2TkA717bDRzec96psyBxp1NEUi12azr93DxI0iYSfrY,3381
2
+ mobius/cli.py,sha256=uhGZIDrhpTp8LkTGOl4BZVQWgJYHbaIVH2HzX3RFCCs,2642
3
+ mobius/constants.py,sha256=wKrWU-QnqMuWuvczHwt3ha3sPw6D9NNK-kIhxJcTbHA,14702
4
+ mobius/crc.py,sha256=WwIfUXGN_UKdsv-_Q303-L1-L1Rc1fvJnB5176ftzqU,2669
5
+ mobius/device.py,sha256=3kR34RLQYd3lbqG8HeRC5HMvD7k5A6XpBfu_eyASNVw,22779
6
+ mobius/discovery.py,sha256=4MjGwpd0bUK-koiZ-a2Cvq-gCTImAKAQ4lZ-9vOge3Q,2290
7
+ mobius/frame.py,sha256=zG2vh8PceJHD8R43MwZiJnm0Ge1dr8nFcKsJR9_c9LM,6135
8
+ mobius/manufacturer.py,sha256=8-vBzckN8xmsPSMj5EWtOCXenbyQueoY5pnZHePgb_k,1937
9
+ mobius/schedule.py,sha256=kHr684TIKPZ2BplIySoeD-Yn1Xa_2bE6rfGUs-waWMQ,7483
10
+ python_mobius-0.1.2.dist-info/METADATA,sha256=ZfFhhqMEx8S5lyZumY2nMBlmO2bW0HrDtwFi8_gKbbE,5959
11
+ python_mobius-0.1.2.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
12
+ python_mobius-0.1.2.dist-info/entry_points.txt,sha256=Q2hlcek-bWm70zvd12v32essflPgC2su-dKDHAqbPoE,48
13
+ python_mobius-0.1.2.dist-info/licenses/LICENSE,sha256=7a72Msu2Q-TnoiFxemxEGkwafJGObk1W3rw9hzmyM_Y,17984
14
+ python_mobius-0.1.2.dist-info/RECORD,,
@@ -1,14 +0,0 @@
1
- mobius/__init__.py,sha256=wtCy-JKP5KhFr3dq_C0V4458R-msyZt2EP96vH16Tr8,3281
2
- mobius/cli.py,sha256=uhGZIDrhpTp8LkTGOl4BZVQWgJYHbaIVH2HzX3RFCCs,2642
3
- mobius/constants.py,sha256=9OtNEOU4FXXhdgayGrz5e_T8s6M20UE31B6GQ-mywnY,12022
4
- mobius/crc.py,sha256=WwIfUXGN_UKdsv-_Q303-L1-L1Rc1fvJnB5176ftzqU,2669
5
- mobius/device.py,sha256=CbCQQjmLQmg-zFufRufcRPkA3PlO_18AkeCP_eDE0EA,19993
6
- mobius/discovery.py,sha256=4MjGwpd0bUK-koiZ-a2Cvq-gCTImAKAQ4lZ-9vOge3Q,2290
7
- mobius/frame.py,sha256=zG2vh8PceJHD8R43MwZiJnm0Ge1dr8nFcKsJR9_c9LM,6135
8
- mobius/manufacturer.py,sha256=8-vBzckN8xmsPSMj5EWtOCXenbyQueoY5pnZHePgb_k,1937
9
- mobius/schedule.py,sha256=kHr684TIKPZ2BplIySoeD-Yn1Xa_2bE6rfGUs-waWMQ,7483
10
- python_mobius-0.1.0.dist-info/METADATA,sha256=pwoXT7fGt7RyDQKMvRzjvNJZaaFtzENzOUWI_OwmELs,5907
11
- python_mobius-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
12
- python_mobius-0.1.0.dist-info/entry_points.txt,sha256=Q2hlcek-bWm70zvd12v32essflPgC2su-dKDHAqbPoE,48
13
- python_mobius-0.1.0.dist-info/licenses/LICENSE,sha256=7a72Msu2Q-TnoiFxemxEGkwafJGObk1W3rw9hzmyM_Y,17984
14
- python_mobius-0.1.0.dist-info/RECORD,,