python-mobius 0.1.0__tar.gz → 0.1.1__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 (32) hide show
  1. {python_mobius-0.1.0 → python_mobius-0.1.1}/CHANGELOG.md +17 -0
  2. {python_mobius-0.1.0 → python_mobius-0.1.1}/PKG-INFO +1 -1
  3. {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/10-known-gaps-and-open-questions.md +10 -0
  4. {python_mobius-0.1.0 → python_mobius-0.1.1}/pyproject.toml +1 -1
  5. {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/__init__.py +5 -3
  6. {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/constants.py +60 -0
  7. {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/device.py +22 -2
  8. python_mobius-0.1.1/tests/test_manufacturer_lookup.py +26 -0
  9. python_mobius-0.1.1/tests/test_serial_decoding.py +20 -0
  10. {python_mobius-0.1.0 → python_mobius-0.1.1}/.gitignore +0 -0
  11. {python_mobius-0.1.0 → python_mobius-0.1.1}/LICENSE +0 -0
  12. {python_mobius-0.1.0 → python_mobius-0.1.1}/README.md +0 -0
  13. {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/00-overview.md +0 -0
  14. {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/01-ble-transport.md +0 -0
  15. {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/02-framing-and-crc.md +0 -0
  16. {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/03-attributes-and-opcodes.md +0 -0
  17. {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/04-device-identity.md +0 -0
  18. {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/05-scenes.md +0 -0
  19. {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/06-light-schedule.md +0 -0
  20. {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/07-pump-schedule.md +0 -0
  21. {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/08-manufacturer-data.md +0 -0
  22. {python_mobius-0.1.0 → python_mobius-0.1.1}/documentation/09-thread-coap-relay.md +0 -0
  23. {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/cli.py +0 -0
  24. {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/crc.py +0 -0
  25. {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/discovery.py +0 -0
  26. {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/frame.py +0 -0
  27. {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/manufacturer.py +0 -0
  28. {python_mobius-0.1.0 → python_mobius-0.1.1}/src/mobius/schedule.py +0 -0
  29. {python_mobius-0.1.0 → python_mobius-0.1.1}/tests/test_frame.py +0 -0
  30. {python_mobius-0.1.0 → python_mobius-0.1.1}/tests/test_light_schedule.py +0 -0
  31. {python_mobius-0.1.0 → python_mobius-0.1.1}/tests/test_manufacturer.py +0 -0
  32. {python_mobius-0.1.0 → python_mobius-0.1.1}/tests/test_pump_schedule.py +0 -0
@@ -1,5 +1,22 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.1
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
+
3
20
  ## 0.1.0 — initial release
4
21
 
5
22
  - Core FSCI/C2CI wire protocol: framing, CRC16, Get/Set-attribute codec.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mobius
3
- Version: 0.1.0
3
+ Version: 0.1.1
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
@@ -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.1"
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" }
@@ -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.1"
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.
@@ -252,8 +271,9 @@ class MobiusDevice:
252
271
  return {
253
272
  "model_raw": model_val,
254
273
  "model": model.name if model is not None else (f"unknown({model_val})" if model_val is not None else None),
274
+ "manufacturer": manufacturer_for_model(model),
255
275
  "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,
276
+ "serial": _decode_serial(serial_raw) if serial_raw else None,
257
277
  "primitive_type": PrimitiveType(primitive_raw[0]).name if primitive_raw else None,
258
278
  "error_state": error.name if error is not None else (f"unknown({error_val})" if error_val is not None else None),
259
279
  "mac_address": ":".join(f"{b:02X}" for b in mac_raw) if mac_raw else None,
@@ -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()
File without changes
File without changes
File without changes