python-mobius 0.2.0__py3-none-any.whl → 0.3.0__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 +5 -1
- mobius/constants.py +172 -5
- mobius/device.py +155 -69
- mobius/modifiers.py +22 -8
- mobius/relay.py +38 -8
- {python_mobius-0.2.0.dist-info → python_mobius-0.3.0.dist-info}/METADATA +1 -1
- {python_mobius-0.2.0.dist-info → python_mobius-0.3.0.dist-info}/RECORD +10 -10
- {python_mobius-0.2.0.dist-info → python_mobius-0.3.0.dist-info}/WHEEL +0 -0
- {python_mobius-0.2.0.dist-info → python_mobius-0.3.0.dist-info}/entry_points.txt +0 -0
- {python_mobius-0.2.0.dist-info → python_mobius-0.3.0.dist-info}/licenses/LICENSE +0 -0
mobius/__init__.py
CHANGED
|
@@ -28,6 +28,8 @@ from .constants import (
|
|
|
28
28
|
SceneID, OperationState, FsciStatus,
|
|
29
29
|
PumpMode, PumpOverrideMode, RampType, PumpParam, PUMP_PARAM_SIZE, PUMP_MODE_PARAMS,
|
|
30
30
|
FirmwareType, FIRMWARE_TYPE_LABELS_ETM, HardwareInfo,
|
|
31
|
+
Color, COLOR_LABELS, RadioType, RADIO_TYPE_LABELS,
|
|
32
|
+
MotorType, MOTOR_TYPE_LABELS, ProductType, PRODUCT_TYPE_LABELS,
|
|
31
33
|
)
|
|
32
34
|
from .schedule import (
|
|
33
35
|
LightPrimitive, SchedulePoint, interpolate_light_schedule,
|
|
@@ -61,7 +63,7 @@ from .discovery import (
|
|
|
61
63
|
dedupe_by_serial, find_device_by_serial, discover_mesh_peers_via_direct_connect,
|
|
62
64
|
)
|
|
63
65
|
|
|
64
|
-
__version__ = "0.
|
|
66
|
+
__version__ = "0.3.0"
|
|
65
67
|
|
|
66
68
|
__all__ = [
|
|
67
69
|
"__version__",
|
|
@@ -83,6 +85,8 @@ __all__ = [
|
|
|
83
85
|
"SceneID", "OperationState", "FsciStatus",
|
|
84
86
|
"PumpMode", "PumpOverrideMode", "RampType", "PumpParam", "PUMP_PARAM_SIZE", "PUMP_MODE_PARAMS",
|
|
85
87
|
"FirmwareType", "FIRMWARE_TYPE_LABELS_ETM", "HardwareInfo",
|
|
88
|
+
"Color", "COLOR_LABELS", "RadioType", "RADIO_TYPE_LABELS",
|
|
89
|
+
"MotorType", "MOTOR_TYPE_LABELS", "ProductType", "PRODUCT_TYPE_LABELS",
|
|
86
90
|
# schedule
|
|
87
91
|
"LightPrimitive", "SchedulePoint", "interpolate_light_schedule",
|
|
88
92
|
"PumpPrimitiveValue", "PumpSchedulePoint", "get_active_pump_block",
|
mobius/constants.py
CHANGED
|
@@ -492,11 +492,12 @@ class HardwareInfo(IntEnum):
|
|
|
492
492
|
"""
|
|
493
493
|
Confirmed literal values from M.HardwareInfo -- the sub-index used
|
|
494
494
|
with C2Attribute.HardwareRevision, same "get all elements" pattern as
|
|
495
|
-
FirmwareVersion above.
|
|
496
|
-
display convention
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
495
|
+
FirmwareVersion above. `Revision` and `Segments` have no confirmed
|
|
496
|
+
display convention (read as plain small integers, not version
|
|
497
|
+
numbers or enum codes) -- but `Color`/`ProductType`/`RadioType`/
|
|
498
|
+
`MotorType` are each themselves confirmed enums (M.Color/M.ProductType/
|
|
499
|
+
M.RadioType/M.MotorType) with confirmed display labels -- see
|
|
500
|
+
Color/ProductType/RadioType/MotorType below and their *_LABELS dicts.
|
|
500
501
|
"""
|
|
501
502
|
Unknown = 0
|
|
502
503
|
Color = 1
|
|
@@ -507,6 +508,172 @@ class HardwareInfo(IntEnum):
|
|
|
507
508
|
Segments = 6
|
|
508
509
|
|
|
509
510
|
|
|
511
|
+
class Color(IntEnum):
|
|
512
|
+
"""Confirmed literal values and display labels from M.Color --
|
|
513
|
+
the HardwareInfo.Color sub-value's own enum."""
|
|
514
|
+
Unknown = 0
|
|
515
|
+
Black = 1
|
|
516
|
+
White = 2
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
COLOR_LABELS: dict = {
|
|
520
|
+
Color.Black: "Black",
|
|
521
|
+
Color.White: "White",
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
class RadioType(IntEnum):
|
|
526
|
+
"""Confirmed literal values and display labels from M.RadioType --
|
|
527
|
+
the HardwareInfo.RadioType sub-value's own enum."""
|
|
528
|
+
Unknown = 0
|
|
529
|
+
KW41 = 1
|
|
530
|
+
QCA4020 = 2
|
|
531
|
+
K32W = 3
|
|
532
|
+
QCA4024 = 4
|
|
533
|
+
ESP32C3 = 5
|
|
534
|
+
ESP32H2 = 6
|
|
535
|
+
ESP32S3 = 7
|
|
536
|
+
ESP32C6 = 8
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
RADIO_TYPE_LABELS: dict = {
|
|
540
|
+
RadioType.KW41: "KW41",
|
|
541
|
+
RadioType.QCA4020: "QCA4020",
|
|
542
|
+
RadioType.K32W: "K32W",
|
|
543
|
+
RadioType.QCA4024: "QCA4024",
|
|
544
|
+
RadioType.ESP32C3: "ESP32C3",
|
|
545
|
+
RadioType.ESP32H2: "ESP32H2",
|
|
546
|
+
RadioType.ESP32S3: "ESP32S3",
|
|
547
|
+
RadioType.ESP32C6: "ESP32C6",
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
class MotorType(IntEnum):
|
|
552
|
+
"""Confirmed literal values and display labels from M.MotorType --
|
|
553
|
+
the HardwareInfo.MotorType sub-value's own enum. Several labels
|
|
554
|
+
(Slack/Alpaca2/Alpaca4/Turtle1-4) are base64-string-obfuscated in the
|
|
555
|
+
decompile rather than plain literals, apparently to avoid casual
|
|
556
|
+
discovery of unannounced product names in a simple APK string dump --
|
|
557
|
+
decoded here since it's plain base64, not encryption, and the
|
|
558
|
+
resulting strings are just as much "confirmed from source" as the
|
|
559
|
+
unobfuscated ones."""
|
|
560
|
+
Unknown = 0
|
|
561
|
+
VorTechMP10 = 1
|
|
562
|
+
VorTechMP40Legacy = 2
|
|
563
|
+
VorTechMP40G3 = 3
|
|
564
|
+
VorTechMP60 = 4
|
|
565
|
+
VectraD12 = 5
|
|
566
|
+
VectraD8 = 6
|
|
567
|
+
VectraS1 = 7
|
|
568
|
+
Wavepuck30w = 8
|
|
569
|
+
Nero5 = 9
|
|
570
|
+
Nero3 = 10
|
|
571
|
+
VectraS2 = 11
|
|
572
|
+
VectraM2 = 12
|
|
573
|
+
VectraL2 = 13
|
|
574
|
+
VersaVX1 = 14
|
|
575
|
+
Slack = 15
|
|
576
|
+
Alpaca2 = 16
|
|
577
|
+
Alpaca4 = 17
|
|
578
|
+
Turtle1 = 18
|
|
579
|
+
Turtle2 = 19
|
|
580
|
+
Turtle3 = 20
|
|
581
|
+
Turtle4 = 21
|
|
582
|
+
Coffee1 = 22
|
|
583
|
+
Coffee2 = 23
|
|
584
|
+
VectraTester = 99
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
MOTOR_TYPE_LABELS: dict = {
|
|
588
|
+
MotorType.VorTechMP10: "VorTech MP10",
|
|
589
|
+
MotorType.VorTechMP40Legacy: "VorTech MP40 Legacy",
|
|
590
|
+
MotorType.VorTechMP40G3: "VorTech MP40 G3",
|
|
591
|
+
MotorType.VorTechMP60: "VorTech MP60",
|
|
592
|
+
MotorType.VectraD12: "Vectra D12",
|
|
593
|
+
MotorType.VectraD8: "Vectra D8",
|
|
594
|
+
MotorType.VectraS1: "Vectra S1",
|
|
595
|
+
MotorType.Wavepuck30w: "Wavepuck 30w",
|
|
596
|
+
MotorType.Nero5: "Nero 5",
|
|
597
|
+
MotorType.Nero3: "Nero 3",
|
|
598
|
+
MotorType.VectraS2: "Vectra S2",
|
|
599
|
+
MotorType.VectraM2: "Vectra M2",
|
|
600
|
+
MotorType.VectraL2: "Vectra L2",
|
|
601
|
+
MotorType.VersaVX1: "Versa VX1",
|
|
602
|
+
MotorType.Slack: "Nero 7",
|
|
603
|
+
MotorType.Alpaca2: "Orbit 2",
|
|
604
|
+
MotorType.Alpaca4: "Orbit 4",
|
|
605
|
+
MotorType.Turtle1: "Axis 20",
|
|
606
|
+
MotorType.Turtle2: "Axis 40",
|
|
607
|
+
MotorType.Turtle3: "Axis 90",
|
|
608
|
+
MotorType.Turtle4: "Axis 200",
|
|
609
|
+
MotorType.Coffee1: "Coffee 1",
|
|
610
|
+
MotorType.Coffee2: "Coffee 2",
|
|
611
|
+
MotorType.VectraTester: "Vectra Tester",
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
class ProductType(IntEnum):
|
|
616
|
+
"""Confirmed literal values and display labels from M.ProductType --
|
|
617
|
+
the HardwareInfo.ProductType sub-value's own enum. Several labels
|
|
618
|
+
are base64-string-obfuscated in the decompile the same way some
|
|
619
|
+
MotorType labels are -- see MotorType's docstring for why that's
|
|
620
|
+
still treated as confirmed. Note several of these labels intentionally
|
|
621
|
+
diverge from the enum member's own name (e.g. Chalupa -> "Sprout",
|
|
622
|
+
NachosBellGrande -> "Voltra", Cowboy -> "MXM") -- these are the actual
|
|
623
|
+
confirmed display strings, not typos."""
|
|
624
|
+
Unknown = 0
|
|
625
|
+
Radion = 1
|
|
626
|
+
Vectra = 2
|
|
627
|
+
VorTech = 3
|
|
628
|
+
Nero = 4
|
|
629
|
+
DosingPump = 5
|
|
630
|
+
Chalupa = 6
|
|
631
|
+
EnvironmentalSensor = 7
|
|
632
|
+
RFModule = 8
|
|
633
|
+
WiFiModule = 9
|
|
634
|
+
WiFiModuleBreakout = 10
|
|
635
|
+
ChalupaPowerSupply = 11
|
|
636
|
+
ChalupaDriver = 12
|
|
637
|
+
AIFiLight = 13
|
|
638
|
+
AIMobiusLight = 14
|
|
639
|
+
DosingPumpBase = 15
|
|
640
|
+
NachosBellGrande = 16
|
|
641
|
+
HotSauce = 17
|
|
642
|
+
Cowboy = 18
|
|
643
|
+
Alpaca = 19
|
|
644
|
+
Turtle = 20
|
|
645
|
+
Coffee = 21
|
|
646
|
+
CrunchyTaco = 22
|
|
647
|
+
SoftTaco = 23
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
PRODUCT_TYPE_LABELS: dict = {
|
|
651
|
+
ProductType.Radion: "Radion",
|
|
652
|
+
ProductType.Vectra: "Vectra",
|
|
653
|
+
ProductType.VorTech: "VorTech",
|
|
654
|
+
ProductType.Nero: "Nero",
|
|
655
|
+
ProductType.DosingPump: "Dosing Pump",
|
|
656
|
+
ProductType.Chalupa: "Sprout",
|
|
657
|
+
ProductType.EnvironmentalSensor: "Sensor",
|
|
658
|
+
ProductType.RFModule: "RF Module",
|
|
659
|
+
ProductType.WiFiModule: "Wi-Fi Module",
|
|
660
|
+
ProductType.WiFiModuleBreakout: "Breakout",
|
|
661
|
+
ProductType.ChalupaPowerSupply: "Power Supply",
|
|
662
|
+
ProductType.ChalupaDriver: "Driver",
|
|
663
|
+
ProductType.AIFiLight: "AI-Fi Light",
|
|
664
|
+
ProductType.AIMobiusLight: "AI Light",
|
|
665
|
+
ProductType.DosingPumpBase: "Base Station",
|
|
666
|
+
ProductType.NachosBellGrande: "Voltra",
|
|
667
|
+
ProductType.HotSauce: "Sensor",
|
|
668
|
+
ProductType.Cowboy: "MXM",
|
|
669
|
+
ProductType.Alpaca: "Orbit",
|
|
670
|
+
ProductType.Turtle: "Axis",
|
|
671
|
+
ProductType.Coffee: "Quantum",
|
|
672
|
+
ProductType.CrunchyTaco: "Hera",
|
|
673
|
+
ProductType.SoftTaco: "Hera Backplane",
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
|
|
510
677
|
class PumpOverrideMode(IntEnum):
|
|
511
678
|
"""
|
|
512
679
|
Confirmed literal values from M.PumpOverrideMode. Confirmed only as a
|
mobius/device.py
CHANGED
|
@@ -31,6 +31,8 @@ from .constants import (
|
|
|
31
31
|
C2Attribute, PrimitiveType, Model, ErrorState, SceneID, OperationState,
|
|
32
32
|
FsciStatus, PhysicalValueID, VisualID, PumpOverrideMode, manufacturer_for_model,
|
|
33
33
|
FirmwareType, FIRMWARE_TYPE_LABELS_ETM, HardwareInfo,
|
|
34
|
+
Color, COLOR_LABELS, RadioType, RADIO_TYPE_LABELS,
|
|
35
|
+
MotorType, MOTOR_TYPE_LABELS, ProductType, PRODUCT_TYPE_LABELS,
|
|
34
36
|
PRIMITIVE_SIZE, LIGHT_PRIMITIVES, PUMP_PRIMITIVES_VERIFIED, PUMP_PRIMITIVES_EXPERIMENTAL,
|
|
35
37
|
)
|
|
36
38
|
from .schedule import (
|
|
@@ -105,6 +107,18 @@ class LightIntensityResult(dict):
|
|
|
105
107
|
self.diagnostics = diagnostics or {}
|
|
106
108
|
|
|
107
109
|
|
|
110
|
+
# Used by get_hardware_info() -- which HardwareInfo sub-fields are
|
|
111
|
+
# themselves confirmed enums with confirmed display labels, vs. plain
|
|
112
|
+
# integers with no confirmed meaning (Revision/Segments, deliberately not
|
|
113
|
+
# in this map).
|
|
114
|
+
_HARDWARE_SUB_ENUM_LABELS = {
|
|
115
|
+
HardwareInfo.Color: (Color, COLOR_LABELS),
|
|
116
|
+
HardwareInfo.ProductType: (ProductType, PRODUCT_TYPE_LABELS),
|
|
117
|
+
HardwareInfo.RadioType: (RadioType, RADIO_TYPE_LABELS),
|
|
118
|
+
HardwareInfo.MotorType: (MotorType, MOTOR_TYPE_LABELS),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
108
122
|
class MobiusDevice:
|
|
109
123
|
"""
|
|
110
124
|
High-level async client for a single Mobius-protocol BLE device.
|
|
@@ -363,16 +377,23 @@ class MobiusDevice:
|
|
|
363
377
|
finally:
|
|
364
378
|
self._pending.pop(message_id, None)
|
|
365
379
|
|
|
366
|
-
async def
|
|
380
|
+
async def get_attribute_raw_all(self, attr_id: int, index: int = 0, count: int = 1) -> list[AttributeValue]:
|
|
367
381
|
"""
|
|
368
|
-
Like
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
382
|
+
Like get_attribute_raw(), but returns EVERY matching AttributeValue
|
|
383
|
+
block in the response, not just the first.
|
|
384
|
+
|
|
385
|
+
Confirmed necessary via real hardware testing: at least some
|
|
386
|
+
"get all elements" responses (FirmwareVersion on a real Radion
|
|
387
|
+
light) split their elements across MULTIPLE separate
|
|
388
|
+
(attrId, index, count, values) blocks within a single response,
|
|
389
|
+
rather than one block covering every element -- a light showing
|
|
390
|
+
8 firmware components in the official app came back as only 5
|
|
391
|
+
via get_attribute_raw(), because it silently returned just the
|
|
392
|
+
first block and discarded the rest. Each block has its OWN
|
|
393
|
+
starting index; callers that use the device-reported index to
|
|
394
|
+
interpret which sub-type each value belongs to (FirmwareType,
|
|
395
|
+
HardwareInfo, VisualID, etc.) need to apply it per-block, not
|
|
396
|
+
assume one single starting index covers every returned value.
|
|
376
397
|
"""
|
|
377
398
|
message_id = next_message_id()
|
|
378
399
|
payload = encode_get_attribute(attr_id, index, count, extended=False)
|
|
@@ -384,12 +405,46 @@ class MobiusDevice:
|
|
|
384
405
|
if status != FsciStatus.Success:
|
|
385
406
|
raise IOError(f"device returned FSCI status {status!r} for attribute {attr_id}")
|
|
386
407
|
attrs = decode_attribute_response(resp.data, extended=False)
|
|
387
|
-
for a in attrs
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
408
|
+
return [a for a in attrs if a.attr_id == attr_id]
|
|
409
|
+
|
|
410
|
+
async def get_attribute_raw(self, attr_id: int, index: int = 0, count: int = 1) -> Optional[AttributeValue]:
|
|
411
|
+
"""
|
|
412
|
+
Like get_attribute(), but returns the full AttributeValue
|
|
413
|
+
(attr_id, index, values) instead of just the values list --
|
|
414
|
+
preserving the device-reported starting index. Needed for
|
|
415
|
+
attributes like MaxPower/NormalPower where the index itself is
|
|
416
|
+
meaningful (index = VisualID byte value the value belongs to), not
|
|
417
|
+
just a positional offset from the requested index. get_attribute()
|
|
418
|
+
is a thin wrapper around this for the common case where you don't
|
|
419
|
+
need the index.
|
|
420
|
+
|
|
421
|
+
Returns only the FIRST matching block -- if the response might be
|
|
422
|
+
split across multiple blocks (confirmed to happen for some "get
|
|
423
|
+
all elements" attributes; see get_attribute_raw_all()'s
|
|
424
|
+
docstring), use that instead and iterate over every block.
|
|
425
|
+
"""
|
|
426
|
+
all_matches = await self.get_attribute_raw_all(attr_id, index, count)
|
|
427
|
+
return all_matches[0] if all_matches else None
|
|
428
|
+
|
|
429
|
+
async def get_attribute_all(self, attr_id: int, index: int = 0, count: int = 1) -> list[bytes]:
|
|
430
|
+
"""
|
|
431
|
+
Like get_attribute(), but merges values from EVERY matching block
|
|
432
|
+
in the response (see get_attribute_raw_all()'s docstring for why
|
|
433
|
+
this matters), for callers that don't need per-block index
|
|
434
|
+
tracking -- e.g. get_supported_channels(), where each value
|
|
435
|
+
encodes its own identity directly rather than relying on
|
|
436
|
+
positional/index information from the response envelope.
|
|
437
|
+
"""
|
|
438
|
+
blocks = await self.get_attribute_raw_all(attr_id, index, count)
|
|
439
|
+
values: list[bytes] = []
|
|
440
|
+
for block in blocks:
|
|
441
|
+
values.extend(block.values)
|
|
442
|
+
return values
|
|
391
443
|
|
|
392
444
|
async def get_attribute(self, attr_id: int, index: int = 0, count: int = 1) -> list[bytes]:
|
|
445
|
+
"""Returns only the FIRST matching block's values -- if the
|
|
446
|
+
response might be split across multiple blocks, use
|
|
447
|
+
get_attribute_all() instead."""
|
|
393
448
|
raw = await self.get_attribute_raw(attr_id, index, count)
|
|
394
449
|
return raw.values if raw else []
|
|
395
450
|
|
|
@@ -733,8 +788,12 @@ class MobiusDevice:
|
|
|
733
788
|
the same "get all elements" pattern as MaxPower/NormalPower/
|
|
734
789
|
SupportedColorChannels. Each returned element's device-reported
|
|
735
790
|
index is the FirmwareType byte value it belongs to (uses
|
|
736
|
-
|
|
737
|
-
does -- the index is meaningful, not just positional
|
|
791
|
+
get_attribute_raw_all() for the same reason get_channel_power_info()
|
|
792
|
+
does -- the index is meaningful, not just positional -- and to
|
|
793
|
+
avoid missing elements from a second block; see that function's
|
|
794
|
+
docstring for why a single response can legitimately split across
|
|
795
|
+
more than one block, confirmed via a real Radion light whose
|
|
796
|
+
FirmwareVersion response came back in two separate blocks).
|
|
738
797
|
|
|
739
798
|
Each version is formatted as a dot-joined string from the raw
|
|
740
799
|
response bytes (e.g. "4.0.21") -- confirmed via
|
|
@@ -752,54 +811,76 @@ class MobiusDevice:
|
|
|
752
811
|
(non-EcoTech) label scheme.
|
|
753
812
|
"""
|
|
754
813
|
try:
|
|
755
|
-
|
|
814
|
+
blocks = await self.get_attribute_raw_all(C2Attribute.FirmwareVersion, index=0, count=0xFFFF)
|
|
756
815
|
except Exception:
|
|
757
816
|
return {}
|
|
758
|
-
if not
|
|
817
|
+
if not blocks:
|
|
759
818
|
return {}
|
|
760
819
|
|
|
761
820
|
use_etm_labels = model is not None and manufacturer_for_model(model) == "EcoTech Marine"
|
|
762
821
|
|
|
763
822
|
result = {}
|
|
764
|
-
for
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
823
|
+
for raw in blocks:
|
|
824
|
+
for i, value_bytes in enumerate(raw.values):
|
|
825
|
+
if not value_bytes:
|
|
826
|
+
continue
|
|
827
|
+
try:
|
|
828
|
+
fw_type = FirmwareType(raw.index + i)
|
|
829
|
+
except ValueError:
|
|
830
|
+
continue
|
|
831
|
+
if use_etm_labels and fw_type in FIRMWARE_TYPE_LABELS_ETM:
|
|
832
|
+
label = FIRMWARE_TYPE_LABELS_ETM[fw_type]
|
|
833
|
+
else:
|
|
834
|
+
label = fw_type.name
|
|
835
|
+
version = ".".join(str(b) for b in value_bytes)
|
|
836
|
+
result[label] = version
|
|
777
837
|
return result
|
|
778
838
|
|
|
779
839
|
async def get_hardware_info(self) -> dict:
|
|
780
840
|
"""
|
|
781
841
|
Fetches HardwareRevision (attribute 2) with index=0, count=0xFFFF
|
|
782
|
-
-- same pattern as get_firmware_versions()
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
842
|
+
-- same pattern as get_firmware_versions(), including using
|
|
843
|
+
get_attribute_raw_all() rather than get_attribute_raw() to avoid
|
|
844
|
+
missing elements if the response splits across more than one
|
|
845
|
+
block (see get_attribute_raw_all()'s docstring).
|
|
846
|
+
|
|
847
|
+
`Color`/`ProductType`/`RadioType`/`MotorType` are each themselves
|
|
848
|
+
confirmed enums with confirmed display labels (M.Color/
|
|
849
|
+
M.ProductType/M.RadioType/M.MotorType in the decompile -- see
|
|
850
|
+
mobius.constants) -- decoded into those label strings here, e.g.
|
|
851
|
+
{"Color": "White", "MotorType": "VorTech MP40 G3"}. An
|
|
852
|
+
unrecognized value (firmware newer than this library's confirmed
|
|
853
|
+
enum coverage) falls back to "Unknown (N)" rather than raising.
|
|
854
|
+
|
|
855
|
+
`Revision`/`Segments` have no confirmed enum meaning -- returned
|
|
856
|
+
as plain integers, not formatted, since unlike the four fields
|
|
857
|
+
above there's nothing confirmed to decode them into.
|
|
788
858
|
"""
|
|
789
859
|
try:
|
|
790
|
-
|
|
860
|
+
blocks = await self.get_attribute_raw_all(C2Attribute.HardwareRevision, index=0, count=0xFFFF)
|
|
791
861
|
except Exception:
|
|
792
862
|
return {}
|
|
793
|
-
if not
|
|
863
|
+
if not blocks:
|
|
794
864
|
return {}
|
|
795
865
|
|
|
796
866
|
result = {}
|
|
797
|
-
for
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
867
|
+
for raw in blocks:
|
|
868
|
+
for i, value_bytes in enumerate(raw.values):
|
|
869
|
+
try:
|
|
870
|
+
hw_info = HardwareInfo(raw.index + i)
|
|
871
|
+
except ValueError:
|
|
872
|
+
continue
|
|
873
|
+
if not value_bytes:
|
|
874
|
+
continue
|
|
875
|
+
raw_int = int.from_bytes(value_bytes, byteorder="little", signed=False)
|
|
876
|
+
if hw_info in _HARDWARE_SUB_ENUM_LABELS:
|
|
877
|
+
enum_cls, labels = _HARDWARE_SUB_ENUM_LABELS[hw_info]
|
|
878
|
+
try:
|
|
879
|
+
result[hw_info.name] = labels.get(enum_cls(raw_int), f"Unknown ({raw_int})")
|
|
880
|
+
except ValueError:
|
|
881
|
+
result[hw_info.name] = f"Unknown ({raw_int})"
|
|
882
|
+
else:
|
|
883
|
+
result[hw_info.name] = raw_int
|
|
803
884
|
return result
|
|
804
885
|
|
|
805
886
|
async def get_device_summary(self) -> dict:
|
|
@@ -823,11 +904,12 @@ class MobiusDevice:
|
|
|
823
904
|
Pump devices additionally include "flow_range", "pump_override_mode",
|
|
824
905
|
"battery_backup", "boosted_battery" (see get_pump_flow_range() etc.
|
|
825
906
|
for details/confidence notes on each). Every device (regardless of
|
|
826
|
-
support tier) additionally includes "group", "calibration",
|
|
827
|
-
"maintenance" (see get_group_info()/
|
|
828
|
-
get_maintenance_info()
|
|
829
|
-
|
|
830
|
-
documentation/
|
|
907
|
+
support tier) additionally includes "group", "calibration",
|
|
908
|
+
"maintenance", and "hardware_info" (see get_group_info()/
|
|
909
|
+
get_calibration_info()/get_maintenance_info()/get_hardware_info()
|
|
910
|
+
-- calibration in particular is expected to only actually
|
|
911
|
+
populate on lights, not pumps; see documentation/
|
|
912
|
+
11-device-status-attributes.md).
|
|
831
913
|
|
|
832
914
|
BUG NOTE: for a while after get_pump_flow_range()/
|
|
833
915
|
get_pump_override_mode()/get_battery_backup_info()/
|
|
@@ -909,6 +991,7 @@ class MobiusDevice:
|
|
|
909
991
|
info["calibration"] = await self.get_calibration_info()
|
|
910
992
|
info["maintenance"] = await self.get_maintenance_info()
|
|
911
993
|
info["firmware_versions"] = await self.get_firmware_versions(model)
|
|
994
|
+
info["hardware_info"] = await self.get_hardware_info()
|
|
912
995
|
info["device_time"] = await self.get_device_time_info()
|
|
913
996
|
|
|
914
997
|
return info
|
|
@@ -1025,7 +1108,7 @@ class MobiusDevice:
|
|
|
1025
1108
|
|
|
1026
1109
|
async def get_supported_channels(self) -> list[VisualID]:
|
|
1027
1110
|
"""Confirmed via Visuals.java: the static list of channels this light has."""
|
|
1028
|
-
raw = await self.
|
|
1111
|
+
raw = await self.get_attribute_all(C2Attribute.SupportedColorChannels, index=0, count=0xFFFF)
|
|
1029
1112
|
out = []
|
|
1030
1113
|
for v in raw:
|
|
1031
1114
|
if v:
|
|
@@ -1165,15 +1248,17 @@ class MobiusDevice:
|
|
|
1165
1248
|
Ported from MaxPower.java/NormalPower.java's parseRequest(): each
|
|
1166
1249
|
returned element's device-reported index is the VisualID byte
|
|
1167
1250
|
value it belongs to (NOT simply its position in the response) --
|
|
1168
|
-
uses
|
|
1169
|
-
|
|
1251
|
+
uses get_attribute_raw_all() rather than get_attribute() for this
|
|
1252
|
+
reason, and to avoid missing elements if the response splits
|
|
1253
|
+
across more than one block (see get_attribute_raw_all()'s
|
|
1254
|
+
docstring).
|
|
1170
1255
|
"""
|
|
1171
1256
|
max_power: dict = {}
|
|
1172
1257
|
try:
|
|
1173
|
-
|
|
1258
|
+
max_blocks = await self.get_attribute_raw_all(C2Attribute.MaxPower, index=0, count=0xFFFF)
|
|
1174
1259
|
except Exception:
|
|
1175
|
-
|
|
1176
|
-
|
|
1260
|
+
max_blocks = []
|
|
1261
|
+
for raw in max_blocks:
|
|
1177
1262
|
for i, value_bytes in enumerate(raw.values):
|
|
1178
1263
|
if len(value_bytes) < 4:
|
|
1179
1264
|
continue
|
|
@@ -1188,22 +1273,23 @@ class MobiusDevice:
|
|
|
1188
1273
|
|
|
1189
1274
|
normal_power: Optional[dict] = None
|
|
1190
1275
|
try:
|
|
1191
|
-
|
|
1276
|
+
normal_blocks = await self.get_attribute_raw_all(C2Attribute.NormalPower, index=0, count=0xFFFF)
|
|
1192
1277
|
except Exception:
|
|
1193
|
-
|
|
1194
|
-
if
|
|
1278
|
+
normal_blocks = []
|
|
1279
|
+
if normal_blocks:
|
|
1195
1280
|
normal_power = {}
|
|
1196
|
-
for
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1281
|
+
for raw2 in normal_blocks:
|
|
1282
|
+
for i, value_bytes in enumerate(raw2.values):
|
|
1283
|
+
if len(value_bytes) < 4:
|
|
1284
|
+
continue
|
|
1285
|
+
watts = struct.unpack("<i", value_bytes)[0]
|
|
1286
|
+
if watts <= 0:
|
|
1287
|
+
continue
|
|
1288
|
+
try:
|
|
1289
|
+
vid = VisualID(raw2.index + i)
|
|
1290
|
+
except ValueError:
|
|
1291
|
+
continue
|
|
1292
|
+
normal_power[vid] = watts
|
|
1207
1293
|
|
|
1208
1294
|
return ChannelPowerInfo(max_power, normal_power)
|
|
1209
1295
|
|
mobius/modifiers.py
CHANGED
|
@@ -131,14 +131,28 @@ class AcclimationInfo:
|
|
|
131
131
|
def is_night_segment(points: list[SchedulePoint], minute_of_day: int) -> bool:
|
|
132
132
|
"""
|
|
133
133
|
True specifically when minute_of_day falls in the segment whose START
|
|
134
|
-
point
|
|
135
|
-
NIGHT (i.e. the
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
134
|
+
point IS flagged NIGHT (but not also SUNRISE) and whose END point is
|
|
135
|
+
ALSO flagged NIGHT (i.e. once actually into the night portion of the
|
|
136
|
+
schedule, up until the sunrise transition), or when minute_of_day
|
|
137
|
+
exactly matches a point that is itself flagged NIGHT. This is NOT
|
|
138
|
+
simply "is it currently night" as a general concept, but this
|
|
139
|
+
specific segment-boundary condition -- used specifically to decide
|
|
140
|
+
whether to substitute the lunar-phase reduction in place of the
|
|
141
141
|
normal schedule-intensity scalar.
|
|
142
|
+
|
|
143
|
+
Confirmed directly from raw smali bytecode (JADX's decompiled Java
|
|
144
|
+
for PointSchedule.getIntensitiesAtTime() carried its own "Code
|
|
145
|
+
duplicated" warning on this exact method -- a real signal, not
|
|
146
|
+
noise: its decompilation of this specific condition had the first
|
|
147
|
+
check inverted, i.e. `!point.has(NIGHT)` instead of the actual
|
|
148
|
+
`point.has(NIGHT)`. Confirmed via real hardware testing too: a
|
|
149
|
+
23:00[NIGHT,SUNSET]->23:27[NIGHT] segment produced a value matching
|
|
150
|
+
schedule-intensity-only scaling from this library, while the real
|
|
151
|
+
app displayed a much lower, lunar-consistent value at the same
|
|
152
|
+
moment -- mathematically impossible to explain by schedule-intensity
|
|
153
|
+
alone within that segment's raw-value range, which is what led back
|
|
154
|
+
to re-examining the raw bytecode instead of trusting the decompiled
|
|
155
|
+
Java a second time.
|
|
142
156
|
"""
|
|
143
157
|
if not points:
|
|
144
158
|
return False
|
|
@@ -160,7 +174,7 @@ def is_night_segment(points: list[SchedulePoint], minute_of_day: int) -> bool:
|
|
|
160
174
|
if t1 == t2:
|
|
161
175
|
return p1.has(SchedulePoint.FLAG_NIGHT)
|
|
162
176
|
return (
|
|
163
|
-
|
|
177
|
+
p1.has(SchedulePoint.FLAG_NIGHT)
|
|
164
178
|
and not p1.has(SchedulePoint.FLAG_SUNRISE)
|
|
165
179
|
and p2.has(SchedulePoint.FLAG_NIGHT)
|
|
166
180
|
)
|
mobius/relay.py
CHANGED
|
@@ -250,14 +250,30 @@ class RelayedMobiusDevice(MobiusDevice):
|
|
|
250
250
|
f"data ({len(inner.data)}B): {inner.data.hex()}")
|
|
251
251
|
return inner
|
|
252
252
|
|
|
253
|
-
async def
|
|
253
|
+
async def get_attribute_raw_all(self, attr_id: int, index: int = 0, count: int = 1) -> list[AttributeValue]:
|
|
254
|
+
"""
|
|
255
|
+
Like get_attribute_raw(), but returns EVERY matching
|
|
256
|
+
AttributeValue block in the relayed response, not just the
|
|
257
|
+
first -- matches MobiusDevice.get_attribute_raw_all()'s reasoning
|
|
258
|
+
exactly (see its docstring): a device's response to a single Get
|
|
259
|
+
request can legitimately split across more than one block, and
|
|
260
|
+
that's just as true when reached via relay as directly, since
|
|
261
|
+
it's the same underlying FSCI response either way, only tunneled
|
|
262
|
+
through CoAP. Confirmed necessary via real hardware testing
|
|
263
|
+
(a Radion light's FirmwareVersion response splitting across two
|
|
264
|
+
blocks) -- the same fix that motivated adding this to
|
|
265
|
+
MobiusDevice applies here for exactly the same reason; without a
|
|
266
|
+
matching override here, callers built on top of this method
|
|
267
|
+
(get_firmware_versions(), get_hardware_info(),
|
|
268
|
+
get_channel_power_info(), get_supported_channels()) would
|
|
269
|
+
silently fall through to MobiusDevice's own implementation when
|
|
270
|
+
called on a relayed device, which tries to use this instance's
|
|
271
|
+
(nonexistent) direct connection instead of relaying at all.
|
|
272
|
+
"""
|
|
254
273
|
payload = encode_get_attribute(attr_id, index, count, extended=False)
|
|
255
274
|
inner_request_frame = build_frame(OPGROUP_C2CI_REQUEST, OPCODE_GET_ATTR, payload)
|
|
256
275
|
inner = await self._relay(inner_request_frame)
|
|
257
276
|
|
|
258
|
-
# Matches get_attribute_raw()'s exact status-checking behavior on
|
|
259
|
-
# MobiusDevice -- relay doesn't change what counts as a valid
|
|
260
|
-
# response, only how the bytes got here.
|
|
261
277
|
status = inner.data[0] if inner.data else FsciStatus.Failed
|
|
262
278
|
if self.debug:
|
|
263
279
|
print(f" [relay debug] attr {attr_id}: status={status!r} "
|
|
@@ -271,10 +287,24 @@ class RelayedMobiusDevice(MobiusDevice):
|
|
|
271
287
|
if self.debug:
|
|
272
288
|
print(f" [relay debug] attr {attr_id}: decoded {len(attrs)} attribute(s): "
|
|
273
289
|
f"{[(a.attr_id, a.index, [v.hex() for v in a.values]) for a in attrs]}")
|
|
274
|
-
for a in attrs
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
290
|
+
return [a for a in attrs if a.attr_id == attr_id]
|
|
291
|
+
|
|
292
|
+
async def get_attribute_raw(self, attr_id: int, index: int = 0, count: int = 1) -> Optional[AttributeValue]:
|
|
293
|
+
"""Returns only the FIRST matching block -- see
|
|
294
|
+
get_attribute_raw_all()'s docstring for why a relayed response
|
|
295
|
+
can have more than one, and use that instead if you need all of
|
|
296
|
+
them."""
|
|
297
|
+
all_matches = await self.get_attribute_raw_all(attr_id, index, count)
|
|
298
|
+
return all_matches[0] if all_matches else None
|
|
299
|
+
|
|
300
|
+
async def get_attribute_all(self, attr_id: int, index: int = 0, count: int = 1) -> list[bytes]:
|
|
301
|
+
"""Merges values from every matching block -- see
|
|
302
|
+
get_attribute_raw_all()'s docstring."""
|
|
303
|
+
blocks = await self.get_attribute_raw_all(attr_id, index, count)
|
|
304
|
+
values: list[bytes] = []
|
|
305
|
+
for block in blocks:
|
|
306
|
+
values.extend(block.values)
|
|
307
|
+
return values
|
|
278
308
|
|
|
279
309
|
async def get_attribute(self, attr_id: int, index: int = 0, count: int = 1) -> list[bytes]:
|
|
280
310
|
raw = await self.get_attribute_raw(attr_id, index, count)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-mobius
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.0
|
|
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
|
|
@@ -1,21 +1,21 @@
|
|
|
1
|
-
mobius/__init__.py,sha256=
|
|
1
|
+
mobius/__init__.py,sha256=m_RB_zd21HCRyOSYovJeLFLScE8Gv9dPLjwdLALGRjk,5596
|
|
2
2
|
mobius/cli.py,sha256=s1zZa4P4rws-XRLLd1B_J67mjMbdImcfgNeDet44cYo,21494
|
|
3
3
|
mobius/coap.py,sha256=n-_wBuSY88_c1TAjfXPZsPnpG0IcZZLFRjxTzd1c0Us,10859
|
|
4
|
-
mobius/constants.py,sha256=
|
|
4
|
+
mobius/constants.py,sha256=N98whYfTAdzydP18kn0ycXQubbc6F-CJUSr-o_KlepU,24129
|
|
5
5
|
mobius/crc.py,sha256=WwIfUXGN_UKdsv-_Q303-L1-L1Rc1fvJnB5176ftzqU,2669
|
|
6
|
-
mobius/device.py,sha256=
|
|
6
|
+
mobius/device.py,sha256=8iNc9fX01Cns00bGYXmSPxXb80THjBd-h47iV02b2KU,70462
|
|
7
7
|
mobius/device_status.py,sha256=373a7BRGY1CoByLYkxTELAewkiSj4Wzm8yAilQrh0co,5812
|
|
8
8
|
mobius/discovery.py,sha256=anJXZEwwhYwvQhFdYOwl6HGTrnMGUfb5CNg6H7Ah-iI,7737
|
|
9
9
|
mobius/frame.py,sha256=zG2vh8PceJHD8R43MwZiJnm0Ge1dr8nFcKsJR9_c9LM,6135
|
|
10
10
|
mobius/manufacturer.py,sha256=8-vBzckN8xmsPSMj5EWtOCXenbyQueoY5pnZHePgb_k,1937
|
|
11
11
|
mobius/mesh_address.py,sha256=PNUi3VyRQZCitB1R3IbzXCg1LcP6azSg1sigHAqaP7E,4942
|
|
12
|
-
mobius/modifiers.py,sha256=
|
|
12
|
+
mobius/modifiers.py,sha256=TQD__OvHAvUO14Z8SNp2I4lb7XYSbw_iZT2D2DXaeJ4,7315
|
|
13
13
|
mobius/power.py,sha256=cZBpfYh2yg4xhmOQ0ILpACYdJSd7jMvSrhxat0yaH-g,3851
|
|
14
14
|
mobius/pump_status.py,sha256=vqJXFAb16S-4oyzAzZIcwR5if7lXoTj67qwBJdbH1gI,1604
|
|
15
|
-
mobius/relay.py,sha256=
|
|
15
|
+
mobius/relay.py,sha256=YnIAtX0vHPPSazgRT_g8ocP2WJNyvW02JQOafabmsbM,16497
|
|
16
16
|
mobius/schedule.py,sha256=kHr684TIKPZ2BplIySoeD-Yn1Xa_2bE6rfGUs-waWMQ,7483
|
|
17
|
-
python_mobius-0.
|
|
18
|
-
python_mobius-0.
|
|
19
|
-
python_mobius-0.
|
|
20
|
-
python_mobius-0.
|
|
21
|
-
python_mobius-0.
|
|
17
|
+
python_mobius-0.3.0.dist-info/METADATA,sha256=8Zxtj2NGCJFkLEm2yXnLs-9ioowAitxS3i3jIrJ9Xtk,5959
|
|
18
|
+
python_mobius-0.3.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
19
|
+
python_mobius-0.3.0.dist-info/entry_points.txt,sha256=Q2hlcek-bWm70zvd12v32essflPgC2su-dKDHAqbPoE,48
|
|
20
|
+
python_mobius-0.3.0.dist-info/licenses/LICENSE,sha256=7a72Msu2Q-TnoiFxemxEGkwafJGObk1W3rw9hzmyM_Y,17984
|
|
21
|
+
python_mobius-0.3.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|