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 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.2.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. Unlike FirmwareVersion, no dot-joined-string
496
- display convention is confirmed for these fields (Color/Revision/
497
- ProductType/RadioType/MotorType/Segments read more like small
498
- integer/enum codes than version numbers) -- returned as raw bytes,
499
- not formatted, until that's confirmed.
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 get_attribute_raw(self, attr_id: int, index: int = 0, count: int = 1) -> Optional[AttributeValue]:
380
+ async def get_attribute_raw_all(self, attr_id: int, index: int = 0, count: int = 1) -> list[AttributeValue]:
367
381
  """
368
- Like get_attribute(), but returns the full AttributeValue
369
- (attr_id, index, values) instead of just the values list --
370
- preserving the device-reported starting index. Needed for
371
- attributes like MaxPower/NormalPower where the index itself is
372
- meaningful (index = VisualID byte value the value belongs to), not
373
- just a positional offset from the requested index. get_attribute()
374
- is a thin wrapper around this for the common case where you don't
375
- need the index.
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
- if a.attr_id == attr_id:
389
- return a
390
- return None
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
- get_attribute_raw() for the same reason get_channel_power_info()
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
- raw = await self.get_attribute_raw(C2Attribute.FirmwareVersion, index=0, count=0xFFFF)
814
+ blocks = await self.get_attribute_raw_all(C2Attribute.FirmwareVersion, index=0, count=0xFFFF)
756
815
  except Exception:
757
816
  return {}
758
- if not raw:
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 i, value_bytes in enumerate(raw.values):
765
- if not value_bytes:
766
- continue
767
- try:
768
- fw_type = FirmwareType(raw.index + i)
769
- except ValueError:
770
- continue
771
- if use_etm_labels and fw_type in FIRMWARE_TYPE_LABELS_ETM:
772
- label = FIRMWARE_TYPE_LABELS_ETM[fw_type]
773
- else:
774
- label = fw_type.name
775
- version = ".".join(str(b) for b in value_bytes)
776
- result[label] = version
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(). Unlike firmware
783
- versions, no dot-joined-string display convention is confirmed for
784
- these fields (Color/Revision/ProductType/RadioType/MotorType/
785
- Segments read more like small integer/enum codes than version
786
- numbers) -- returns {HardwareInfo_name: raw_bytes}, not formatted,
787
- until that's confirmed against real hardware.
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
- raw = await self.get_attribute_raw(C2Attribute.HardwareRevision, index=0, count=0xFFFF)
860
+ blocks = await self.get_attribute_raw_all(C2Attribute.HardwareRevision, index=0, count=0xFFFF)
791
861
  except Exception:
792
862
  return {}
793
- if not raw:
863
+ if not blocks:
794
864
  return {}
795
865
 
796
866
  result = {}
797
- for i, value_bytes in enumerate(raw.values):
798
- try:
799
- hw_info = HardwareInfo(raw.index + i)
800
- except ValueError:
801
- continue
802
- result[hw_info.name] = value_bytes
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", and
827
- "maintenance" (see get_group_info()/get_calibration_info()/
828
- get_maintenance_info() -- calibration in particular is expected to
829
- only actually populate on lights, not pumps; see
830
- documentation/11-device-status-attributes.md).
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.get_attribute(C2Attribute.SupportedColorChannels, index=0, count=0xFFFF)
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 get_attribute_raw() rather than get_attribute() specifically
1169
- because of this.
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
- raw = await self.get_attribute_raw(C2Attribute.MaxPower, index=0, count=0xFFFF)
1258
+ max_blocks = await self.get_attribute_raw_all(C2Attribute.MaxPower, index=0, count=0xFFFF)
1174
1259
  except Exception:
1175
- raw = None
1176
- if raw:
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
- raw2 = await self.get_attribute_raw(C2Attribute.NormalPower, index=0, count=0xFFFF)
1276
+ normal_blocks = await self.get_attribute_raw_all(C2Attribute.NormalPower, index=0, count=0xFFFF)
1192
1277
  except Exception:
1193
- raw2 = None
1194
- if raw2:
1278
+ normal_blocks = []
1279
+ if normal_blocks:
1195
1280
  normal_power = {}
1196
- for i, value_bytes in enumerate(raw2.values):
1197
- if len(value_bytes) < 4:
1198
- continue
1199
- watts = struct.unpack("<i", value_bytes)[0]
1200
- if watts <= 0:
1201
- continue
1202
- try:
1203
- vid = VisualID(raw2.index + i)
1204
- except ValueError:
1205
- continue
1206
- normal_power[vid] = watts
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 is NOT flagged NIGHT or SUNRISE and whose END point IS flagged
135
- NIGHT (i.e. the dusk-transition-into-night segment), or when
136
- minute_of_day exactly matches a point that is itself flagged NIGHT.
137
- This is NOT simply "is it currently night" -- that's genuinely the
138
- Java source's own semantics (confirmed by reading
139
- PointSchedule.getIntensitiesAtTime() directly), used specifically to
140
- decide whether to substitute the lunar-phase reduction in place of the
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
- not p1.has(SchedulePoint.FLAG_NIGHT)
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 get_attribute_raw(self, attr_id: int, index: int = 0, count: int = 1) -> Optional[AttributeValue]:
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
- if a.attr_id == attr_id:
276
- return a
277
- return None
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.2.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=tyvUO4ImAlcND0wQnJ0aMmC4DPB3ZRZXNVBdqz93LC4,5334
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=rXaywY6WIK36HobZ1E3w-HfEWtZDCFzXqKbo4Bbe1V8,19259
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=f-B4q0IZlQl3C78mwLJLHQL5O-LcqzVUeYZ6dUiXe0I,65558
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=pFNjGYX-_bNumNEpv3jJLYPCb9ZuSKYpQ0DDd2GmicQ,6451
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=6fBNGUa43JBWY-ZVGKVXR45OgnR7qcFPGjURd0_tvB4,14683
15
+ mobius/relay.py,sha256=YnIAtX0vHPPSazgRT_g8ocP2WJNyvW02JQOafabmsbM,16497
16
16
  mobius/schedule.py,sha256=kHr684TIKPZ2BplIySoeD-Yn1Xa_2bE6rfGUs-waWMQ,7483
17
- python_mobius-0.2.0.dist-info/METADATA,sha256=GOOfNmYXMp0wC-wbm6_L3MctCVQYhyPiasSSXPF4ddA,5959
18
- python_mobius-0.2.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
19
- python_mobius-0.2.0.dist-info/entry_points.txt,sha256=Q2hlcek-bWm70zvd12v32essflPgC2su-dKDHAqbPoE,48
20
- python_mobius-0.2.0.dist-info/licenses/LICENSE,sha256=7a72Msu2Q-TnoiFxemxEGkwafJGObk1W3rw9hzmyM_Y,17984
21
- python_mobius-0.2.0.dist-info/RECORD,,
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,,