python-mobius 0.2.0__py3-none-any.whl → 0.2.1__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
@@ -61,7 +61,7 @@ from .discovery import (
61
61
  dedupe_by_serial, find_device_by_serial, discover_mesh_peers_via_direct_connect,
62
62
  )
63
63
 
64
- __version__ = "0.2.0"
64
+ __version__ = "0.2.1"
65
65
 
66
66
  __all__ = [
67
67
  "__version__",
mobius/device.py CHANGED
@@ -363,16 +363,23 @@ class MobiusDevice:
363
363
  finally:
364
364
  self._pending.pop(message_id, None)
365
365
 
366
- async def get_attribute_raw(self, attr_id: int, index: int = 0, count: int = 1) -> Optional[AttributeValue]:
366
+ async def get_attribute_raw_all(self, attr_id: int, index: int = 0, count: int = 1) -> list[AttributeValue]:
367
367
  """
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.
368
+ Like get_attribute_raw(), but returns EVERY matching AttributeValue
369
+ block in the response, not just the first.
370
+
371
+ Confirmed necessary via real hardware testing: at least some
372
+ "get all elements" responses (FirmwareVersion on a real Radion
373
+ light) split their elements across MULTIPLE separate
374
+ (attrId, index, count, values) blocks within a single response,
375
+ rather than one block covering every element -- a light showing
376
+ 8 firmware components in the official app came back as only 5
377
+ via get_attribute_raw(), because it silently returned just the
378
+ first block and discarded the rest. Each block has its OWN
379
+ starting index; callers that use the device-reported index to
380
+ interpret which sub-type each value belongs to (FirmwareType,
381
+ HardwareInfo, VisualID, etc.) need to apply it per-block, not
382
+ assume one single starting index covers every returned value.
376
383
  """
377
384
  message_id = next_message_id()
378
385
  payload = encode_get_attribute(attr_id, index, count, extended=False)
@@ -384,12 +391,46 @@ class MobiusDevice:
384
391
  if status != FsciStatus.Success:
385
392
  raise IOError(f"device returned FSCI status {status!r} for attribute {attr_id}")
386
393
  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
394
+ return [a for a in attrs if a.attr_id == attr_id]
395
+
396
+ async def get_attribute_raw(self, attr_id: int, index: int = 0, count: int = 1) -> Optional[AttributeValue]:
397
+ """
398
+ Like get_attribute(), but returns the full AttributeValue
399
+ (attr_id, index, values) instead of just the values list --
400
+ preserving the device-reported starting index. Needed for
401
+ attributes like MaxPower/NormalPower where the index itself is
402
+ meaningful (index = VisualID byte value the value belongs to), not
403
+ just a positional offset from the requested index. get_attribute()
404
+ is a thin wrapper around this for the common case where you don't
405
+ need the index.
406
+
407
+ Returns only the FIRST matching block -- if the response might be
408
+ split across multiple blocks (confirmed to happen for some "get
409
+ all elements" attributes; see get_attribute_raw_all()'s
410
+ docstring), use that instead and iterate over every block.
411
+ """
412
+ all_matches = await self.get_attribute_raw_all(attr_id, index, count)
413
+ return all_matches[0] if all_matches else None
414
+
415
+ async def get_attribute_all(self, attr_id: int, index: int = 0, count: int = 1) -> list[bytes]:
416
+ """
417
+ Like get_attribute(), but merges values from EVERY matching block
418
+ in the response (see get_attribute_raw_all()'s docstring for why
419
+ this matters), for callers that don't need per-block index
420
+ tracking -- e.g. get_supported_channels(), where each value
421
+ encodes its own identity directly rather than relying on
422
+ positional/index information from the response envelope.
423
+ """
424
+ blocks = await self.get_attribute_raw_all(attr_id, index, count)
425
+ values: list[bytes] = []
426
+ for block in blocks:
427
+ values.extend(block.values)
428
+ return values
391
429
 
392
430
  async def get_attribute(self, attr_id: int, index: int = 0, count: int = 1) -> list[bytes]:
431
+ """Returns only the FIRST matching block's values -- if the
432
+ response might be split across multiple blocks, use
433
+ get_attribute_all() instead."""
393
434
  raw = await self.get_attribute_raw(attr_id, index, count)
394
435
  return raw.values if raw else []
395
436
 
@@ -733,8 +774,12 @@ class MobiusDevice:
733
774
  the same "get all elements" pattern as MaxPower/NormalPower/
734
775
  SupportedColorChannels. Each returned element's device-reported
735
776
  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).
777
+ get_attribute_raw_all() for the same reason get_channel_power_info()
778
+ does -- the index is meaningful, not just positional -- and to
779
+ avoid missing elements from a second block; see that function's
780
+ docstring for why a single response can legitimately split across
781
+ more than one block, confirmed via a real Radion light whose
782
+ FirmwareVersion response came back in two separate blocks).
738
783
 
739
784
  Each version is formatted as a dot-joined string from the raw
740
785
  response bytes (e.g. "4.0.21") -- confirmed via
@@ -752,34 +797,38 @@ class MobiusDevice:
752
797
  (non-EcoTech) label scheme.
753
798
  """
754
799
  try:
755
- raw = await self.get_attribute_raw(C2Attribute.FirmwareVersion, index=0, count=0xFFFF)
800
+ blocks = await self.get_attribute_raw_all(C2Attribute.FirmwareVersion, index=0, count=0xFFFF)
756
801
  except Exception:
757
802
  return {}
758
- if not raw:
803
+ if not blocks:
759
804
  return {}
760
805
 
761
806
  use_etm_labels = model is not None and manufacturer_for_model(model) == "EcoTech Marine"
762
807
 
763
808
  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
809
+ for raw in blocks:
810
+ for i, value_bytes in enumerate(raw.values):
811
+ if not value_bytes:
812
+ continue
813
+ try:
814
+ fw_type = FirmwareType(raw.index + i)
815
+ except ValueError:
816
+ continue
817
+ if use_etm_labels and fw_type in FIRMWARE_TYPE_LABELS_ETM:
818
+ label = FIRMWARE_TYPE_LABELS_ETM[fw_type]
819
+ else:
820
+ label = fw_type.name
821
+ version = ".".join(str(b) for b in value_bytes)
822
+ result[label] = version
777
823
  return result
778
824
 
779
825
  async def get_hardware_info(self) -> dict:
780
826
  """
781
827
  Fetches HardwareRevision (attribute 2) with index=0, count=0xFFFF
782
- -- same pattern as get_firmware_versions(). Unlike firmware
828
+ -- same pattern as get_firmware_versions(), including using
829
+ get_attribute_raw_all() rather than get_attribute_raw() to avoid
830
+ missing elements if the response splits across more than one
831
+ block (see get_attribute_raw_all()'s docstring). Unlike firmware
783
832
  versions, no dot-joined-string display convention is confirmed for
784
833
  these fields (Color/Revision/ProductType/RadioType/MotorType/
785
834
  Segments read more like small integer/enum codes than version
@@ -787,19 +836,20 @@ class MobiusDevice:
787
836
  until that's confirmed against real hardware.
788
837
  """
789
838
  try:
790
- raw = await self.get_attribute_raw(C2Attribute.HardwareRevision, index=0, count=0xFFFF)
839
+ blocks = await self.get_attribute_raw_all(C2Attribute.HardwareRevision, index=0, count=0xFFFF)
791
840
  except Exception:
792
841
  return {}
793
- if not raw:
842
+ if not blocks:
794
843
  return {}
795
844
 
796
845
  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
846
+ for raw in blocks:
847
+ for i, value_bytes in enumerate(raw.values):
848
+ try:
849
+ hw_info = HardwareInfo(raw.index + i)
850
+ except ValueError:
851
+ continue
852
+ result[hw_info.name] = value_bytes
803
853
  return result
804
854
 
805
855
  async def get_device_summary(self) -> dict:
@@ -1025,7 +1075,7 @@ class MobiusDevice:
1025
1075
 
1026
1076
  async def get_supported_channels(self) -> list[VisualID]:
1027
1077
  """Confirmed via Visuals.java: the static list of channels this light has."""
1028
- raw = await self.get_attribute(C2Attribute.SupportedColorChannels, index=0, count=0xFFFF)
1078
+ raw = await self.get_attribute_all(C2Attribute.SupportedColorChannels, index=0, count=0xFFFF)
1029
1079
  out = []
1030
1080
  for v in raw:
1031
1081
  if v:
@@ -1165,15 +1215,17 @@ class MobiusDevice:
1165
1215
  Ported from MaxPower.java/NormalPower.java's parseRequest(): each
1166
1216
  returned element's device-reported index is the VisualID byte
1167
1217
  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.
1218
+ uses get_attribute_raw_all() rather than get_attribute() for this
1219
+ reason, and to avoid missing elements if the response splits
1220
+ across more than one block (see get_attribute_raw_all()'s
1221
+ docstring).
1170
1222
  """
1171
1223
  max_power: dict = {}
1172
1224
  try:
1173
- raw = await self.get_attribute_raw(C2Attribute.MaxPower, index=0, count=0xFFFF)
1225
+ max_blocks = await self.get_attribute_raw_all(C2Attribute.MaxPower, index=0, count=0xFFFF)
1174
1226
  except Exception:
1175
- raw = None
1176
- if raw:
1227
+ max_blocks = []
1228
+ for raw in max_blocks:
1177
1229
  for i, value_bytes in enumerate(raw.values):
1178
1230
  if len(value_bytes) < 4:
1179
1231
  continue
@@ -1188,22 +1240,23 @@ class MobiusDevice:
1188
1240
 
1189
1241
  normal_power: Optional[dict] = None
1190
1242
  try:
1191
- raw2 = await self.get_attribute_raw(C2Attribute.NormalPower, index=0, count=0xFFFF)
1243
+ normal_blocks = await self.get_attribute_raw_all(C2Attribute.NormalPower, index=0, count=0xFFFF)
1192
1244
  except Exception:
1193
- raw2 = None
1194
- if raw2:
1245
+ normal_blocks = []
1246
+ if normal_blocks:
1195
1247
  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
1248
+ for raw2 in normal_blocks:
1249
+ for i, value_bytes in enumerate(raw2.values):
1250
+ if len(value_bytes) < 4:
1251
+ continue
1252
+ watts = struct.unpack("<i", value_bytes)[0]
1253
+ if watts <= 0:
1254
+ continue
1255
+ try:
1256
+ vid = VisualID(raw2.index + i)
1257
+ except ValueError:
1258
+ continue
1259
+ normal_power[vid] = watts
1207
1260
 
1208
1261
  return ChannelPowerInfo(max_power, normal_power)
1209
1262
 
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.2.1
4
4
  Summary: Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)
5
5
  Project-URL: Homepage, https://code.r3pek.org/r3pek/python-mobius
6
6
  Project-URL: Documentation, https://code.r3pek.org/r3pek/python-mobius/src/branch/main/documentation
@@ -1,9 +1,9 @@
1
- mobius/__init__.py,sha256=tyvUO4ImAlcND0wQnJ0aMmC4DPB3ZRZXNVBdqz93LC4,5334
1
+ mobius/__init__.py,sha256=LxDxIbucDGqf1DhloOxCxeC8Ta2rYM0Sy3T2UGYkXKI,5334
2
2
  mobius/cli.py,sha256=s1zZa4P4rws-XRLLd1B_J67mjMbdImcfgNeDet44cYo,21494
3
3
  mobius/coap.py,sha256=n-_wBuSY88_c1TAjfXPZsPnpG0IcZZLFRjxTzd1c0Us,10859
4
4
  mobius/constants.py,sha256=rXaywY6WIK36HobZ1E3w-HfEWtZDCFzXqKbo4Bbe1V8,19259
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=GZ-Ucqt7lE_teOjXdHBf7DbgiTGtry88KwMr7a3NmHk,68859
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
@@ -12,10 +12,10 @@ mobius/mesh_address.py,sha256=PNUi3VyRQZCitB1R3IbzXCg1LcP6azSg1sigHAqaP7E,4942
12
12
  mobius/modifiers.py,sha256=pFNjGYX-_bNumNEpv3jJLYPCb9ZuSKYpQ0DDd2GmicQ,6451
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.2.1.dist-info/METADATA,sha256=EHtIqy5HkJRrHGzPnbgdpM4PoAPyTnqgJU3lKpTt56U,5959
18
+ python_mobius-0.2.1.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
19
+ python_mobius-0.2.1.dist-info/entry_points.txt,sha256=Q2hlcek-bWm70zvd12v32essflPgC2su-dKDHAqbPoE,48
20
+ python_mobius-0.2.1.dist-info/licenses/LICENSE,sha256=7a72Msu2Q-TnoiFxemxEGkwafJGObk1W3rw9hzmyM_Y,17984
21
+ python_mobius-0.2.1.dist-info/RECORD,,