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 +27 -5
- mobius/cli.py +60 -3
- mobius/constants.py +103 -0
- mobius/device.py +635 -23
- mobius/device_status.py +89 -0
- mobius/discovery.py +53 -0
- mobius/modifiers.py +167 -0
- mobius/power.py +90 -0
- mobius/pump_status.py +50 -0
- {python_mobius-0.1.2.dist-info → python_mobius-0.1.4.dist-info}/METADATA +1 -1
- python_mobius-0.1.4.dist-info/RECORD +18 -0
- python_mobius-0.1.2.dist-info/RECORD +0 -14
- {python_mobius-0.1.2.dist-info → python_mobius-0.1.4.dist-info}/WHEEL +0 -0
- {python_mobius-0.1.2.dist-info → python_mobius-0.1.4.dist-info}/entry_points.txt +0 -0
- {python_mobius-0.1.2.dist-info → python_mobius-0.1.4.dist-info}/licenses/LICENSE +0 -0
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.
|
|
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
|
|
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,
|
|
68
|
+
for device, info in found:
|
|
39
69
|
print(f"--- {device.address} ({device.name}) ---")
|
|
40
70
|
try:
|
|
41
|
-
|
|
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
|