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 +1 -1
- mobius/device.py +112 -59
- mobius/relay.py +38 -8
- {python_mobius-0.2.0.dist-info → python_mobius-0.2.1.dist-info}/METADATA +1 -1
- {python_mobius-0.2.0.dist-info → python_mobius-0.2.1.dist-info}/RECORD +8 -8
- {python_mobius-0.2.0.dist-info → python_mobius-0.2.1.dist-info}/WHEEL +0 -0
- {python_mobius-0.2.0.dist-info → python_mobius-0.2.1.dist-info}/entry_points.txt +0 -0
- {python_mobius-0.2.0.dist-info → python_mobius-0.2.1.dist-info}/licenses/LICENSE +0 -0
mobius/__init__.py
CHANGED
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
|
|
366
|
+
async def get_attribute_raw_all(self, attr_id: int, index: int = 0, count: int = 1) -> list[AttributeValue]:
|
|
367
367
|
"""
|
|
368
|
-
Like
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
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
|
-
|
|
800
|
+
blocks = await self.get_attribute_raw_all(C2Attribute.FirmwareVersion, index=0, count=0xFFFF)
|
|
756
801
|
except Exception:
|
|
757
802
|
return {}
|
|
758
|
-
if not
|
|
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
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
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()
|
|
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
|
-
|
|
839
|
+
blocks = await self.get_attribute_raw_all(C2Attribute.HardwareRevision, index=0, count=0xFFFF)
|
|
791
840
|
except Exception:
|
|
792
841
|
return {}
|
|
793
|
-
if not
|
|
842
|
+
if not blocks:
|
|
794
843
|
return {}
|
|
795
844
|
|
|
796
845
|
result = {}
|
|
797
|
-
for
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
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.
|
|
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
|
|
1169
|
-
|
|
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
|
-
|
|
1225
|
+
max_blocks = await self.get_attribute_raw_all(C2Attribute.MaxPower, index=0, count=0xFFFF)
|
|
1174
1226
|
except Exception:
|
|
1175
|
-
|
|
1176
|
-
|
|
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
|
-
|
|
1243
|
+
normal_blocks = await self.get_attribute_raw_all(C2Attribute.NormalPower, index=0, count=0xFFFF)
|
|
1192
1244
|
except Exception:
|
|
1193
|
-
|
|
1194
|
-
if
|
|
1245
|
+
normal_blocks = []
|
|
1246
|
+
if normal_blocks:
|
|
1195
1247
|
normal_power = {}
|
|
1196
|
-
for
|
|
1197
|
-
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
|
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.2.
|
|
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=
|
|
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=
|
|
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=
|
|
15
|
+
mobius/relay.py,sha256=YnIAtX0vHPPSazgRT_g8ocP2WJNyvW02JQOafabmsbM,16497
|
|
16
16
|
mobius/schedule.py,sha256=kHr684TIKPZ2BplIySoeD-Yn1Xa_2bE6rfGUs-waWMQ,7483
|
|
17
|
-
python_mobius-0.2.
|
|
18
|
-
python_mobius-0.2.
|
|
19
|
-
python_mobius-0.2.
|
|
20
|
-
python_mobius-0.2.
|
|
21
|
-
python_mobius-0.2.
|
|
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,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|