python-mobius 0.1.2__py3-none-any.whl → 0.1.4__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
@@ -26,22 +26,32 @@ from .constants import (
26
26
  Model, MODEL_MANUFACTURER, manufacturer_for_model,
27
27
  ErrorState, C2Attribute, PhysicalValueID, VisualID,
28
28
  SceneID, OperationState, FsciStatus,
29
- PumpMode, RampType, PumpParam, PUMP_PARAM_SIZE, PUMP_MODE_PARAMS,
29
+ PumpMode, PumpOverrideMode, RampType, PumpParam, PUMP_PARAM_SIZE, PUMP_MODE_PARAMS,
30
+ FirmwareType, FIRMWARE_TYPE_LABELS_ETM, HardwareInfo,
30
31
  )
31
32
  from .schedule import (
32
33
  LightPrimitive, SchedulePoint, interpolate_light_schedule,
33
34
  PumpPrimitiveValue, PumpSchedulePoint, get_active_pump_block,
34
35
  )
35
36
  from .manufacturer import MOBIUS_COMPANY_ID, MobiusAdvertisement, parse_manufacturer_data
37
+ from .modifiers import (
38
+ AcclimationInfo, is_night_segment,
39
+ julian_day_from_date, lunar_days_into_phase, lunar_percent_reduction,
40
+ LUNAR_CYCLE_SCALERS,
41
+ )
42
+ from .power import ChannelPowerInfo, channel_percent_value
43
+ from .pump_status import PumpFlowRange, BatteryBackupInfo, BoostedBatteryInfo
44
+ from .device_status import GroupInfo, CalibrationInfo, MaintenanceInfo
36
45
  from .device import (
37
- MobiusDevice, MobiusPump,
46
+ MobiusDevice, MobiusPump, LightIntensityResult,
38
47
  SERVICE_GENERAL, CHAR_RX_DATA, CHAR_RX_FINAL, CHAR_TX_DATA, CHAR_TX_FINAL,
39
48
  )
40
49
  from .discovery import (
41
50
  scan_for_mobius_devices, scan_for_mobius_devices_with_info, group_by_pan_id,
51
+ dedupe_by_serial, find_device_by_serial,
42
52
  )
43
53
 
44
- __version__ = "0.1.2"
54
+ __version__ = "0.1.4"
45
55
 
46
56
  __all__ = [
47
57
  "__version__",
@@ -61,15 +71,27 @@ __all__ = [
61
71
  "Model", "MODEL_MANUFACTURER", "manufacturer_for_model",
62
72
  "ErrorState", "C2Attribute", "PhysicalValueID", "VisualID",
63
73
  "SceneID", "OperationState", "FsciStatus",
64
- "PumpMode", "RampType", "PumpParam", "PUMP_PARAM_SIZE", "PUMP_MODE_PARAMS",
74
+ "PumpMode", "PumpOverrideMode", "RampType", "PumpParam", "PUMP_PARAM_SIZE", "PUMP_MODE_PARAMS",
75
+ "FirmwareType", "FIRMWARE_TYPE_LABELS_ETM", "HardwareInfo",
65
76
  # schedule
66
77
  "LightPrimitive", "SchedulePoint", "interpolate_light_schedule",
67
78
  "PumpPrimitiveValue", "PumpSchedulePoint", "get_active_pump_block",
68
79
  # manufacturer
69
80
  "MOBIUS_COMPANY_ID", "MobiusAdvertisement", "parse_manufacturer_data",
81
+ # modifiers
82
+ "AcclimationInfo", "is_night_segment",
83
+ "julian_day_from_date", "lunar_days_into_phase", "lunar_percent_reduction",
84
+ "LUNAR_CYCLE_SCALERS",
85
+ # power
86
+ "ChannelPowerInfo", "channel_percent_value",
87
+ # pump_status
88
+ "PumpFlowRange", "BatteryBackupInfo", "BoostedBatteryInfo",
89
+ # device_status
90
+ "GroupInfo", "CalibrationInfo", "MaintenanceInfo",
70
91
  # device
71
- "MobiusDevice", "MobiusPump",
92
+ "MobiusDevice", "MobiusPump", "LightIntensityResult",
72
93
  "SERVICE_GENERAL", "CHAR_RX_DATA", "CHAR_RX_FINAL", "CHAR_TX_DATA", "CHAR_TX_FINAL",
73
94
  # discovery
74
95
  "scan_for_mobius_devices", "scan_for_mobius_devices_with_info", "group_by_pan_id",
96
+ "dedupe_by_serial", "find_device_by_serial",
75
97
  ]
mobius/cli.py CHANGED
@@ -11,15 +11,45 @@ import argparse
11
11
  import asyncio
12
12
 
13
13
  from .device import MobiusDevice
14
- from .discovery import scan_for_mobius_devices_with_info, group_by_pan_id
14
+ from .discovery import (
15
+ scan_for_mobius_devices_with_info, group_by_pan_id, dedupe_by_serial,
16
+ find_device_by_serial,
17
+ )
15
18
 
16
19
 
17
20
  async def _run(args) -> None:
21
+ if args.by_serial:
22
+ # Focused path for testing serial-based reconnection directly --
23
+ # useful for confirming this actually works against real hardware
24
+ # whose BLE address may have changed since it was last seen. See
25
+ # documentation/12-device-identity-and-address-stability.md.
26
+ print(f"=== Resolving current address for serial {args.by_serial!r} ===")
27
+ found = await find_device_by_serial(args.by_serial, timeout=args.timeout, adapter=args.adapter)
28
+ if found is None:
29
+ print(f"No device currently advertising serial {args.by_serial!r} found.")
30
+ return
31
+ device, info = found
32
+ print(f" currently at {device.address} model={info.model.name if info.model else info.model_raw}")
33
+ try:
34
+ async with MobiusDevice(serial=args.by_serial, adapter=args.adapter,
35
+ connect_timeout=args.connect_timeout) as mdevice:
36
+ summary = await mdevice.get_device_summary()
37
+ for k, v in summary.items():
38
+ print(f" {k}: {v}")
39
+ except Exception as e:
40
+ print(" failed to connect/read:", e)
41
+ return
42
+
18
43
  found = await scan_for_mobius_devices_with_info(timeout=args.timeout, adapter=args.adapter)
19
44
  if not found:
20
45
  print("No Mobius devices found. Make sure BLE is on and devices are nearby.")
21
46
  return
22
47
 
48
+ # Guards against seeing the same device twice under two different
49
+ # addresses if caught mid-address-rotation during this one scan -- see
50
+ # documentation/12-device-identity-and-address-stability.md.
51
+ found = dedupe_by_serial(found)
52
+
23
53
  print("=== Pan ID groups (from advertisements, no connection needed) ===")
24
54
  for pan_id, members in group_by_pan_id(found).items():
25
55
  label = f"{pan_id:#06x}" if pan_id is not None else "unknown"
@@ -35,10 +65,32 @@ async def _run(args) -> None:
35
65
  return
36
66
 
37
67
  print("\n=== Full device summaries (connects to each) ===")
38
- for device, _info in found:
68
+ for device, info in found:
39
69
  print(f"--- {device.address} ({device.name}) ---")
40
70
  try:
41
- async with MobiusDevice(device, connect_timeout=args.connect_timeout) as mdevice:
71
+ # BUG FIX: passing both `device` (the scan-time BLEDevice) and
72
+ # `serial` here does NOT actually get you serial-based
73
+ # robustness -- MobiusDevice.connect() always uses a directly-
74
+ # provided BLEDevice on the FIRST connect, and this loop only
75
+ # ever calls connect() once per device (one `async with` block,
76
+ # not a reused instance), so the serial fallback path never
77
+ # triggered. That scan-time BLEDevice can be tens of seconds
78
+ # stale by the time we reach the last device in a long scan --
79
+ # exactly the "device disappeared" symptom reported against
80
+ # real hardware. Passing `serial=` ALONE (omitting `device`
81
+ # entirely) forces a fresh resolution right before each
82
+ # connect, instead of trusting a potentially-stale reference.
83
+ # Costs one extra short scan per device with a serial; falls
84
+ # back to the original (scan-time) BLEDevice only when we
85
+ # don't have a serial to resolve with. See documentation/
86
+ # 12-device-identity-and-address-stability.md.
87
+ serial = info.serial if info else None
88
+ if serial is not None:
89
+ mobius_device = MobiusDevice(serial=serial, adapter=args.adapter,
90
+ connect_timeout=args.connect_timeout)
91
+ else:
92
+ mobius_device = MobiusDevice(device, connect_timeout=args.connect_timeout)
93
+ async with mobius_device as mdevice:
42
94
  summary = await mdevice.get_device_summary()
43
95
  for k, v in summary.items():
44
96
  print(f" {k}: {v}")
@@ -60,6 +112,11 @@ def main() -> None:
60
112
  help="Per-device connect timeout in seconds (default: 30.0)")
61
113
  parser.add_argument("--scan-only", action="store_true",
62
114
  help="Only scan/group by pan_id; don't connect to any device.")
115
+ parser.add_argument("--by-serial", default=None, metavar="SERIAL",
116
+ help="Skip the normal scan/group flow and instead resolve + connect "
117
+ "to whichever device is CURRENTLY advertising this serial number, "
118
+ "regardless of its BLE address. Useful for testing serial-based "
119
+ "reconnection against real hardware whose address may have changed.")
63
120
  args = parser.parse_args()
64
121
  asyncio.run(_run(args))
65
122
 
mobius/constants.py CHANGED
@@ -252,10 +252,26 @@ class C2Attribute(IntEnum):
252
252
  BatteryBackupSpeed = 706
253
253
  MinimumGallonsPerHour = 707
254
254
  MaximumGallonsPerHour = 708
255
+ GroupMaster = 900
256
+ IsGroupMaster = 911
257
+ IsCalibrated = 1300
258
+ LastCalibrationTime = 1304
259
+ MinCalibratedSpeed = 1308
260
+ MaxCalibratedSpeed = 1309
261
+ RecommendedMaintenanceInterval = 1400
262
+ MaintenanceTimer = 1401
263
+ LastMaintenanceTime = 1402
264
+ MaintenanceDue = 1403
255
265
  BoostedBatteryPower = 803
256
266
  BoostedBatteryPowerOnTime = 804
257
267
  BoostedBatteryPowerOffTime = 805
258
268
  SupportedColorChannels = 901
269
+ AcclimationEnabled = 902
270
+ AcclimationPeriod = 903
271
+ AcclimationStartIntensity = 904
272
+ AcclimationStartTime = 905
273
+ LunarPhasesEnabled = 907
274
+ InsolationEnabled = 912
259
275
  MaxPower = 1504
260
276
  Ramp = 1505
261
277
  NormalPower = 1513
@@ -402,6 +418,93 @@ class RampType(IntEnum):
402
418
  Linear = 3
403
419
 
404
420
 
421
+ class FirmwareType(IntEnum):
422
+ """
423
+ Confirmed literal values from M.FirmwareType -- the sub-index used
424
+ with C2Attribute.FirmwareVersion (a Get with index=0, count=0xFFFF
425
+ returns one entry per firmware component the device has, same "get
426
+ all elements" pattern as MaxPower/NormalPower/SupportedColorChannels).
427
+ """
428
+ Unknown = 0
429
+ OS = 1
430
+ Bootloader = 2
431
+ MainMicroOS = 3
432
+ MainMicroBootloader = 4
433
+ QCA4020Firmware = 5
434
+ QCA4020FileSystem = 6
435
+ QCA4020M4F = 7
436
+ QCA4020M0 = 8
437
+ QCA4020WLAN = 9
438
+ XMC1402Firmware = 10
439
+ LEDClusterMicro = 11
440
+ Esp32C3Firmware = 12
441
+ Esp32H2Firmware = 13
442
+ CoffeeMotorDriverFirmware = 14
443
+ Esp32S3Firmware = 15
444
+ Esp32C6Firmware = 16
445
+
446
+
447
+ # Ported directly from M.FirmwareType.name(Model) -- the EcoTech Marine
448
+ # (isEtm()) branch specifically, confirmed via real hardware to exactly
449
+ # match the app's own displayed labels (a VorTech pump showed Radio/Radio
450
+ # Bootloader/Product OS/Product Bootloader; a Radion light showed Product
451
+ # OS/Product Bootloader/Radio Firmware/Filesystem/Radio OS/Radio/WLAN/
452
+ # Firmware). AquaIllumination (non-ETM) models use a different mapping in
453
+ # the same Java method, not ported here since it's outside what's been
454
+ # verified against real hardware.
455
+ FIRMWARE_TYPE_LABELS_ETM: dict = {
456
+ FirmwareType.Unknown: "Firmware",
457
+ FirmwareType.LEDClusterMicro: "Firmware",
458
+ FirmwareType.Esp32C3Firmware: "Firmware",
459
+ FirmwareType.Esp32H2Firmware: "Firmware",
460
+ FirmwareType.Esp32S3Firmware: "Firmware",
461
+ FirmwareType.Esp32C6Firmware: "Firmware",
462
+ FirmwareType.OS: "Radio",
463
+ FirmwareType.Bootloader: "Radio Bootloader",
464
+ FirmwareType.MainMicroOS: "Product OS",
465
+ FirmwareType.MainMicroBootloader: "Product Bootloader",
466
+ FirmwareType.QCA4020Firmware: "Radio Firmware",
467
+ FirmwareType.QCA4020FileSystem: "Filesystem",
468
+ FirmwareType.QCA4020M4F: "Radio OS",
469
+ FirmwareType.QCA4020M0: "Radio",
470
+ FirmwareType.QCA4020WLAN: "WLAN",
471
+ }
472
+
473
+
474
+ class HardwareInfo(IntEnum):
475
+ """
476
+ Confirmed literal values from M.HardwareInfo -- the sub-index used
477
+ with C2Attribute.HardwareRevision, same "get all elements" pattern as
478
+ FirmwareVersion above. Unlike FirmwareVersion, no dot-joined-string
479
+ display convention is confirmed for these fields (Color/Revision/
480
+ ProductType/RadioType/MotorType/Segments read more like small
481
+ integer/enum codes than version numbers) -- returned as raw bytes,
482
+ not formatted, until that's confirmed.
483
+ """
484
+ Unknown = 0
485
+ Color = 1
486
+ Revision = 2
487
+ ProductType = 3
488
+ RadioType = 4
489
+ MotorType = 5
490
+ Segments = 6
491
+
492
+
493
+ class PumpOverrideMode(IntEnum):
494
+ """
495
+ Confirmed literal values from M.PumpOverrideMode. Confirmed only as a
496
+ SET target in the decompile (writing None to exit override mode after
497
+ the pump-side calibration flow) -- never actually read anywhere in the
498
+ app. The wire format for reading it (plain byte enum) is a reasonable
499
+ inference from the enum's own definition and how every other
500
+ byte-backed enum attribute in this protocol works, not something
501
+ directly observed being read.
502
+ """
503
+ None_ = 0 # trailing underscore: "None" is a Python keyword
504
+ AllOff = 1
505
+ Manual = 2
506
+
507
+
405
508
  class PumpParam(IntEnum):
406
509
  """Named fields that can appear in a PumpPrimitive, per pump mode.
407
510
  Not a wire-format enum itself -- just internal bookkeeping for