python-mobius 0.1.0__tar.gz → 0.1.2__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. python_mobius-0.1.2/CHANGELOG.md +69 -0
  2. {python_mobius-0.1.0 → python_mobius-0.1.2}/PKG-INFO +2 -1
  3. {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/03-attributes-and-opcodes.md +1 -1
  4. {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/10-known-gaps-and-open-questions.md +10 -0
  5. {python_mobius-0.1.0 → python_mobius-0.1.2}/pyproject.toml +3 -2
  6. {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/__init__.py +5 -3
  7. {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/constants.py +60 -0
  8. {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/device.py +63 -9
  9. python_mobius-0.1.2/tests/test_connection_cleanup.py +107 -0
  10. python_mobius-0.1.2/tests/test_manufacturer_lookup.py +26 -0
  11. python_mobius-0.1.2/tests/test_serial_decoding.py +20 -0
  12. python_mobius-0.1.0/CHANGELOG.md +0 -29
  13. {python_mobius-0.1.0 → python_mobius-0.1.2}/.gitignore +0 -0
  14. {python_mobius-0.1.0 → python_mobius-0.1.2}/LICENSE +0 -0
  15. {python_mobius-0.1.0 → python_mobius-0.1.2}/README.md +0 -0
  16. {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/00-overview.md +0 -0
  17. {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/01-ble-transport.md +0 -0
  18. {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/02-framing-and-crc.md +0 -0
  19. {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/04-device-identity.md +0 -0
  20. {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/05-scenes.md +0 -0
  21. {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/06-light-schedule.md +0 -0
  22. {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/07-pump-schedule.md +0 -0
  23. {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/08-manufacturer-data.md +0 -0
  24. {python_mobius-0.1.0 → python_mobius-0.1.2}/documentation/09-thread-coap-relay.md +0 -0
  25. {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/cli.py +0 -0
  26. {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/crc.py +0 -0
  27. {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/discovery.py +0 -0
  28. {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/frame.py +0 -0
  29. {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/manufacturer.py +0 -0
  30. {python_mobius-0.1.0 → python_mobius-0.1.2}/src/mobius/schedule.py +0 -0
  31. {python_mobius-0.1.0 → python_mobius-0.1.2}/tests/test_frame.py +0 -0
  32. {python_mobius-0.1.0 → python_mobius-0.1.2}/tests/test_light_schedule.py +0 -0
  33. {python_mobius-0.1.0 → python_mobius-0.1.2}/tests/test_manufacturer.py +0 -0
  34. {python_mobius-0.1.0 → python_mobius-0.1.2}/tests/test_pump_schedule.py +0 -0
@@ -0,0 +1,69 @@
1
+ # Changelog
2
+
3
+ ## 0.1.2
4
+
5
+ - Added `manufacturer_for_model()` / `MODEL_MANUFACTURER` (ported from
6
+ `M.Model.getManufacturer()`) — surfaces "EcoTech Marine" /
7
+ "AquaIllumination" / "Neptune Systems" / "NYOS" per device model. Now
8
+ included automatically in `get_device_info()`'s `"manufacturer"` field.
9
+ Added while building a Home Assistant integration on top of this library,
10
+ which needs a manufacturer string for its device registry.
11
+ - `get_device_info()`'s `"serial"` field is now decoded as ASCII text
12
+ instead of hex-encoded raw bytes (e.g. `"7V4Z00F143RBED"` instead of
13
+ `"3756345a30304631343352424544"`). Confirmed printable ASCII on every
14
+ device tested, via cross-validation against the same serial parsed from
15
+ BLE advertisement manufacturer data. Falls back to hex for any
16
+ non-printable-ASCII edge case not yet observed. Found while building the
17
+ Home Assistant integration on top of this library -- the hex-encoded
18
+ serial was producing unnecessarily long/ugly entity IDs.
19
+ - `get_pump_telemetry()` now includes a confirmed `"speed_percent"` field
20
+ alongside the existing raw `"speed"`. `MotorSpeed` is confirmed (via the
21
+ decompiled app's own display code) to be a percentage of max pump power
22
+ in tenths of a percent, NOT RPM -- `String.format("%.0f%%",
23
+ Math.abs(currentSpeed / 10.0f))` is literally what the app does to this
24
+ exact value. `speed_percent` is that same `abs(speed)/10` conversion;
25
+ `speed` is still returned raw since its sign encodes rotation direction
26
+ (reverse), which `speed_percent` discards.
27
+ - Fixed a real connection leak in `MobiusDevice.connect()`: if
28
+ `start_notify()` failed after the BLE connection was already
29
+ established (a known common flaky point right after a fresh connection),
30
+ the exception propagated out of `connect()` (`__aenter__`) -- and per the
31
+ async context manager protocol, a raising `__aenter__` means `__aexit__`
32
+ (and therefore `disconnect()`) is never called by the caller's `async
33
+ with` block. The already-open connection was left orphaned on the
34
+ adapter. Since callers like Home Assistant coordinators retry
35
+ periodically, this leak compounded over repeated failures, plausibly
36
+ exhausting a real adapter's connection-slot pool over time even with no
37
+ other integration competing for it and without any range/signal issue.
38
+ `connect()` now cleans up (disconnects) before re-raising if anything
39
+ fails after the connection is established. Added regression tests
40
+ confirming both the cleanup-on-failure and no-spurious-disconnect-on-
41
+ success behavior.
42
+
43
+ ## 0.1.0 — initial release
44
+
45
+ - Core FSCI/C2CI wire protocol: framing, CRC16, Get/Set-attribute codec.
46
+ Verified byte-for-byte against real captured packets.
47
+ - Scene control (`start_scene`, `start_feed_mode`, `resume_schedule`).
48
+ - Pump telemetry (speed, estimated GPH) — verified live against real
49
+ VorTech MP40QD pumps.
50
+ - Pump schedule reading (mode + parameters per time block) — verified live.
51
+ - Light schedule reading + client-side interpolation, matching the app's
52
+ own behavior — verified live against real Radion XR15 G6 Pro lights.
53
+ - Device discovery, identity (`get_device_info`), and a unified
54
+ `get_device_summary()` with explicit support tiers
55
+ (light / pump / pump-experimental / unsupported).
56
+ - Advertisement-only discovery: model, serial, and `pan_id` (tank/mesh
57
+ grouping) with no GATT connection required — verified against 4 real
58
+ devices sharing one `pan_id`.
59
+ - `mobius-scan` CLI.
60
+
61
+ ### Known gaps
62
+
63
+ - `DoseV1` and `HotSauceV1` primitive types have no parser (see
64
+ `documentation/10-known-gaps-and-open-questions.md`).
65
+ - `CoffeeV1` (NYOS Quantum) pump-primitive support is structurally
66
+ plausible but unverified against real hardware.
67
+ - Thread/CoAP device-to-device relay (used by the official app to reach
68
+ devices without a direct BLE connection) is not implemented — this
69
+ library always connects directly to each device instead.
@@ -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'
@@ -87,7 +87,7 @@ against real pumps and lights returned correctly-structured, sane data.
87
87
  | `ErrorState` | 107 | See [04-device-identity.md](./04-device-identity.md) |
88
88
  | `CurrentScene` | 401 | Set to `sceneId(u16 LE) + durationMinutes(u16 LE)` to start a scene |
89
89
  | `Schedule1` / `Schedule2` | 500 / 503 | The programmed point-schedule — see 06/07 |
90
- | `MotorSpeed` | 700 | Live pump speed (int16) |
90
+ | `MotorSpeed` | 700 | Live pump speed (int16). Confirmed NOT RPM -- percentage of max power in tenths of a percent (abs(value)/10 = %); sign encodes rotation direction. Confirmed via the app's own display code, `String.format("%.0f%%", Math.abs(currentSpeed / 10.0f))`. |
91
91
  | `SupportedColorChannels` | 901 | Static list of a light's channels |
92
92
 
93
93
  See `src/mobius/constants.py` for the complete implemented list.
@@ -34,6 +34,16 @@ included as a starting point.
34
34
  - **`MACAddress`** (attribute 103): returned empty/unpopulated on every
35
35
  device tested. Might be Thread/legacy-radio-only and simply not set on
36
36
  BLE-only-provisioned devices, or might need a different query context.
37
+ - **The unit of `PhysicalValueID.GallonsPerHour`**: high confidence it's
38
+ genuinely US gallons per hour, not empirically flow-metered. Basis: (1)
39
+ the decompiled source's own attribute name is literally
40
+ `GallonsPerHour`, EcoTech's own naming, not an inference; (2) EcoTech is
41
+ a US company that markets VorTech flow exclusively in GPH; (3) real
42
+ captured values (2,272 and 2,877 GPH, both from the same partial-power
43
+ speed setting) land at roughly 45-65% of the MP40wQD's own published
44
+ 4,500-5,000+ GPH max rating -- exactly the proportion expected for a
45
+ pump running below full power. No independent flow-meter verification
46
+ has been done against real hardware.
37
47
 
38
48
  ## Deliberately not implemented
39
49
 
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-mobius"
7
- version = "0.1.0"
7
+ version = "0.1.2"
8
8
  description = "Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)"
9
9
  readme = "README.md"
10
10
  license = { text = "GPL-2.0-only" }
@@ -35,7 +35,7 @@ dependencies = [
35
35
 
36
36
  [project.optional-dependencies]
37
37
  retry = ["bleak-retry-connector>=3.0"]
38
- dev = ["pytest>=7.0", "bleak-retry-connector>=3.0"]
38
+ dev = ["pytest>=7.0", "pytest-asyncio>=0.23", "bleak-retry-connector>=3.0"]
39
39
 
40
40
  [project.scripts]
41
41
  mobius-scan = "mobius.cli:main"
@@ -50,3 +50,4 @@ packages = ["src/mobius"]
50
50
 
51
51
  [tool.pytest.ini_options]
52
52
  testpaths = ["tests"]
53
+ asyncio_mode = "auto"
@@ -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
@@ -11,6 +11,7 @@ See documentation/ for the full derivation of each table.
11
11
  from __future__ import annotations
12
12
 
13
13
  from enum import IntEnum
14
+ from typing import Optional
14
15
 
15
16
 
16
17
  # --------------------------------------------------------------------------
@@ -107,6 +108,65 @@ class Model(IntEnum):
107
108
  Coffee2 = 397 # "Quantum 220/300" (NYOS)
108
109
 
109
110
 
111
+ # Ported from M.Model.getManufacturer(). The decompiled version branches on
112
+ # enum *ordinal* against a hardcoded position list (the full ~100-entry
113
+ # Model enum's index 1-37 range, plus a few extras, all map to "EcoTech
114
+ # Marine"); since this library only implements a curated subset of Model,
115
+ # this is re-derived by name pattern against that same logic instead of by
116
+ # ordinal position -- functionally equivalent for every Model value defined
117
+ # above, but if you add more Model entries, add their manufacturer here too.
118
+ MODEL_MANUFACTURER: dict[Model, str] = {
119
+ Model.Unknown: "Unknown",
120
+ Model.VorTechMP10wES: "EcoTech Marine",
121
+ Model.VorTechMP10wQD: "EcoTech Marine",
122
+ Model.VorTechMP40wES: "EcoTech Marine",
123
+ Model.VorTechMP40wQD: "EcoTech Marine",
124
+ Model.VorTechMP40wG3QD: "EcoTech Marine",
125
+ Model.VorTechMP60wES: "EcoTech Marine",
126
+ Model.VorTechMP60wQD: "EcoTech Marine",
127
+ Model.RadionXR30w: "EcoTech Marine",
128
+ Model.RadionXR30wG2: "EcoTech Marine",
129
+ Model.RadionXR30wPro: "EcoTech Marine",
130
+ Model.RadionXR30wG3: "EcoTech Marine",
131
+ Model.RadionXR30wG3Pro: "EcoTech Marine",
132
+ Model.RadionXR30wG4: "EcoTech Marine",
133
+ Model.RadionXR30wG4Pro: "EcoTech Marine",
134
+ Model.RadionXR30wFWPro: "EcoTech Marine",
135
+ Model.RadionXR30wPrototype: "EcoTech Marine",
136
+ Model.RadionXR15wG3Pro: "EcoTech Marine",
137
+ Model.RadionXR15wFW: "EcoTech Marine",
138
+ Model.RadionXR15wG4Pro: "EcoTech Marine",
139
+ Model.RadionXR15wFWPro: "EcoTech Marine",
140
+ Model.RadionXR15wPrototype: "EcoTech Marine",
141
+ Model.RadionXR15G5Pro: "EcoTech Marine",
142
+ Model.RadionXR15wG5Blue: "EcoTech Marine",
143
+ Model.RadionXR15wG5FW: "EcoTech Marine",
144
+ Model.RadionXR15wG6Pro: "EcoTech Marine",
145
+ Model.RadionXR15wG6Blue: "EcoTech Marine",
146
+ Model.RadionXR30wG5Pro: "EcoTech Marine",
147
+ Model.RadionXR30wG5Blue: "EcoTech Marine",
148
+ Model.VectraM1: "EcoTech Marine",
149
+ Model.VectraL1: "EcoTech Marine",
150
+ Model.VectraS1: "EcoTech Marine",
151
+ Model.VectraS2: "EcoTech Marine",
152
+ Model.VectraM2: "EcoTech Marine",
153
+ Model.VectraL2: "EcoTech Marine",
154
+ Model.Nero5: "AquaIllumination",
155
+ Model.Nero3: "AquaIllumination",
156
+ Model.DosingPump: "AquaIllumination",
157
+ Model.Muffler: "Neptune Systems", # confirmed explicitly in the decompile's manufacturer list
158
+ Model.Coffee1: "NYOS", # confirmed explicitly (CoffeeV1 branch)
159
+ Model.Coffee2: "NYOS",
160
+ }
161
+
162
+
163
+ def manufacturer_for_model(model: Optional[Model]) -> Optional[str]:
164
+ """Returns the manufacturer name for a Model, or None if unknown/unmapped."""
165
+ if model is None:
166
+ return None
167
+ return MODEL_MANUFACTURER.get(model)
168
+
169
+
110
170
  class ErrorState(IntEnum):
111
171
  """Confirmed literal values from M.ErrorState (subset covering common
112
172
  pump/light fault codes -- the full enum also has dosing-pump-specific codes)."""
@@ -29,7 +29,7 @@ from .frame import (
29
29
  )
30
30
  from .constants import (
31
31
  C2Attribute, PrimitiveType, Model, ErrorState, SceneID, OperationState,
32
- FsciStatus, PhysicalValueID, VisualID,
32
+ FsciStatus, PhysicalValueID, VisualID, manufacturer_for_model,
33
33
  PRIMITIVE_SIZE, LIGHT_PRIMITIVES, PUMP_PRIMITIVES_VERIFIED, PUMP_PRIMITIVES_EXPERIMENTAL,
34
34
  )
35
35
  from .schedule import (
@@ -48,6 +48,25 @@ CHAR_TX_DATA = "01ff0103-ba5e-f4ee-5ca1-eb1e5e4b1ce0" # write: app -> device,
48
48
  CHAR_TX_FINAL = "01ff0104-ba5e-f4ee-5ca1-eb1e5e4b1ce0" # write: app -> device, last fragment
49
49
 
50
50
 
51
+ def _decode_serial(raw: bytes) -> str:
52
+ """
53
+ Serial numbers are printable ASCII on every device tested (confirmed by
54
+ cross-validating the GATT-read serial against the same serial parsed
55
+ from BLE advertisement manufacturer data -- see manufacturer.py and
56
+ documentation/08-manufacturer-data.md), so decode as text rather than
57
+ hex-encoding raw bytes. Falls back to hex for any non-printable-ASCII
58
+ edge case not yet observed, rather than raising.
59
+ """
60
+ text = raw.rstrip(b"\x00")
61
+ try:
62
+ decoded = text.decode("ascii")
63
+ except UnicodeDecodeError:
64
+ return raw.hex()
65
+ if decoded and all(32 <= ord(c) < 127 for c in decoded):
66
+ return decoded
67
+ return raw.hex()
68
+
69
+
51
70
  class MobiusDevice:
52
71
  """
53
72
  High-level async client for a single Mobius-protocol BLE device.
@@ -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
 
@@ -0,0 +1,107 @@
1
+ """
2
+ Regression test for a real leak: if start_notify() fails after the BLE
3
+ connection is already established, connect() (== __aenter__) must clean up
4
+ the connection before re-raising -- otherwise a raising __aenter__ means
5
+ __aexit__/disconnect() is never called by the caller's `async with` block,
6
+ leaking an orphaned open connection on the adapter every time it happens.
7
+ """
8
+
9
+ from unittest.mock import AsyncMock, MagicMock, patch
10
+
11
+ import pytest
12
+ from bleak.backends.device import BLEDevice
13
+
14
+ from mobius.device import MobiusDevice
15
+
16
+
17
+ def _fake_ble_device():
18
+ return BLEDevice("AA:BB:CC:DD:EE:FF", "MOBIUS", {})
19
+
20
+
21
+ @pytest.mark.asyncio
22
+ async def test_failed_start_notify_disconnects_before_raising():
23
+ fake_client = MagicMock()
24
+ fake_client.is_connected = True
25
+ fake_client.disconnect = AsyncMock()
26
+ fake_client.start_notify = AsyncMock(side_effect=RuntimeError("GATT notify setup failed"))
27
+
28
+ async def fake_establish_connection(*args, **kwargs):
29
+ return fake_client
30
+
31
+ with patch("mobius.device.establish_connection", side_effect=fake_establish_connection), \
32
+ patch("mobius.device._HAVE_RETRY_CONNECTOR", True):
33
+ device = MobiusDevice(_fake_ble_device())
34
+ with pytest.raises(RuntimeError, match="GATT notify setup failed"):
35
+ await device.connect()
36
+
37
+ # The key assertion: even though connect() raised, the already-open
38
+ # connection was cleaned up rather than left orphaned.
39
+ fake_client.disconnect.assert_awaited_once()
40
+
41
+
42
+ @pytest.mark.asyncio
43
+ async def test_successful_connect_does_not_disconnect():
44
+ fake_client = MagicMock()
45
+ fake_client.is_connected = True
46
+ fake_client.disconnect = AsyncMock()
47
+ fake_client.start_notify = AsyncMock(return_value=None)
48
+
49
+ async def fake_establish_connection(*args, **kwargs):
50
+ return fake_client
51
+
52
+ with patch("mobius.device.establish_connection", side_effect=fake_establish_connection), \
53
+ patch("mobius.device._HAVE_RETRY_CONNECTOR", True):
54
+ device = MobiusDevice(_fake_ble_device())
55
+ await device.connect()
56
+
57
+ fake_client.disconnect.assert_not_awaited()
58
+ assert device._client is fake_client
59
+
60
+
61
+ @pytest.mark.asyncio
62
+ async def test_pump_telemetry_speed_percent_matches_app_formula():
63
+ """
64
+ Confirmed (not inferred) against the decompiled app's own display code:
65
+ FlowFragment.java / WidgetViewCell.java format MotorSpeed as
66
+ String.format("%.0f%%", Math.abs(currentSpeed / 10.0f)) -- i.e. speed is
67
+ a percentage of max power in tenths of a percent, not RPM, with sign
68
+ encoding rotation direction. Uses real captured values from a live
69
+ VorTech pump (speed=447 -> 44.7%).
70
+ """
71
+ fake_client = MagicMock()
72
+ fake_client.is_connected = True
73
+ fake_client.disconnect = AsyncMock()
74
+ fake_client.start_notify = AsyncMock(return_value=None)
75
+
76
+ device = MobiusDevice(_fake_ble_device())
77
+ device._client = fake_client
78
+
79
+ async def fake_get_attribute(attr_id, index=0, count=1):
80
+ if attr_id == 700: # MotorSpeed
81
+ return [(447).to_bytes(2, "little", signed=True)]
82
+ return [(2272).to_bytes(4, "little", signed=True)] # GallonsPerHour
83
+
84
+ device.get_attribute = fake_get_attribute
85
+ telemetry = await device.get_pump_telemetry()
86
+
87
+ assert telemetry["speed"] == 447
88
+ assert telemetry["speed_percent"] == 44.7
89
+ assert telemetry["gph"] == 2272
90
+
91
+
92
+ @pytest.mark.asyncio
93
+ async def test_pump_telemetry_speed_percent_is_always_nonnegative():
94
+ """A negative raw speed (reverse rotation) should still give a
95
+ non-negative speed_percent."""
96
+ device = MobiusDevice(_fake_ble_device())
97
+
98
+ async def fake_get_attribute(attr_id, index=0, count=1):
99
+ if attr_id == 700:
100
+ return [(-447).to_bytes(2, "little", signed=True)]
101
+ return [(2272).to_bytes(4, "little", signed=True)]
102
+
103
+ device.get_attribute = fake_get_attribute
104
+ telemetry = await device.get_pump_telemetry()
105
+
106
+ assert telemetry["speed"] == -447
107
+ assert telemetry["speed_percent"] == 44.7
@@ -0,0 +1,26 @@
1
+ from mobius import Model, manufacturer_for_model
2
+
3
+
4
+ def test_vortech_is_ecotech_marine():
5
+ assert manufacturer_for_model(Model.VorTechMP40wG3QD) == "EcoTech Marine"
6
+
7
+
8
+ def test_radion_is_ecotech_marine():
9
+ assert manufacturer_for_model(Model.RadionXR15wG6Pro) == "EcoTech Marine"
10
+
11
+
12
+ def test_nero_is_aquaillumination():
13
+ assert manufacturer_for_model(Model.Nero5) == "AquaIllumination"
14
+
15
+
16
+ def test_coffee_is_nyos():
17
+ assert manufacturer_for_model(Model.Coffee1) == "NYOS"
18
+ assert manufacturer_for_model(Model.Coffee2) == "NYOS"
19
+
20
+
21
+ def test_muffler_is_neptune_systems():
22
+ assert manufacturer_for_model(Model.Muffler) == "Neptune Systems"
23
+
24
+
25
+ def test_none_model_returns_none():
26
+ assert manufacturer_for_model(None) is None
@@ -0,0 +1,20 @@
1
+ from mobius.device import _decode_serial
2
+
3
+
4
+ def test_decodes_real_pump_serial_as_ascii():
5
+ # Real captured serial from a VorTech MP40QD pump.
6
+ assert _decode_serial(bytes.fromhex("3736343935323231303539303139")) == "76495221059019"
7
+
8
+
9
+ def test_decodes_real_light_serial_as_ascii():
10
+ # Real captured serial from a Radion XR15 G6 Pro.
11
+ assert _decode_serial(bytes.fromhex("3756345a30304631343352424544")) == "7V4Z00F143RBED"
12
+
13
+
14
+ def test_strips_trailing_nulls():
15
+ assert _decode_serial(b"ABC123\x00\x00\x00") == "ABC123"
16
+
17
+
18
+ def test_falls_back_to_hex_for_non_ascii():
19
+ raw = bytes([0, 1, 2, 3])
20
+ assert _decode_serial(raw) == raw.hex()
@@ -1,29 +0,0 @@
1
- # Changelog
2
-
3
- ## 0.1.0 — initial release
4
-
5
- - Core FSCI/C2CI wire protocol: framing, CRC16, Get/Set-attribute codec.
6
- Verified byte-for-byte against real captured packets.
7
- - Scene control (`start_scene`, `start_feed_mode`, `resume_schedule`).
8
- - Pump telemetry (speed, estimated GPH) — verified live against real
9
- VorTech MP40QD pumps.
10
- - Pump schedule reading (mode + parameters per time block) — verified live.
11
- - Light schedule reading + client-side interpolation, matching the app's
12
- own behavior — verified live against real Radion XR15 G6 Pro lights.
13
- - Device discovery, identity (`get_device_info`), and a unified
14
- `get_device_summary()` with explicit support tiers
15
- (light / pump / pump-experimental / unsupported).
16
- - Advertisement-only discovery: model, serial, and `pan_id` (tank/mesh
17
- grouping) with no GATT connection required — verified against 4 real
18
- devices sharing one `pan_id`.
19
- - `mobius-scan` CLI.
20
-
21
- ### Known gaps
22
-
23
- - `DoseV1` and `HotSauceV1` primitive types have no parser (see
24
- `documentation/10-known-gaps-and-open-questions.md`).
25
- - `CoffeeV1` (NYOS Quantum) pump-primitive support is structurally
26
- plausible but unverified against real hardware.
27
- - Thread/CoAP device-to-device relay (used by the official app to reach
28
- devices without a direct BLE connection) is not implemented — this
29
- library always connects directly to each device instead.
File without changes
File without changes
File without changes