python-mobius 0.5.0__py3-none-any.whl → 0.7.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
@@ -13,7 +13,7 @@ protocol writeup and confirmation evidence for every field.
13
13
  from .crc import crc16, CRC16_TABLE
14
14
  from .frame import (
15
15
  build_frame, parse_frame, ParsedFrame,
16
- encode_get_attribute, encode_set_attribute, decode_attribute_response, AttributeValue,
16
+ encode_get_attribute, encode_get_attributes_batch, encode_set_attribute, decode_attribute_response, AttributeValue,
17
17
  OPGROUP_C2CI_REQUEST, OPGROUP_C2CI_CONFIRM,
18
18
  OPGROUP_FSCI_REQUEST, OPGROUP_FSCI_CONFIRM,
19
19
  OPGROUP_QOTAP_REQUEST, OPGROUP_QOTAP_CONFIRM,
@@ -43,7 +43,7 @@ from .modifiers import (
43
43
  )
44
44
  from .power import ChannelPowerInfo, channel_percent_value
45
45
  from .pump_status import PumpFlowRange, BatteryBackupInfo, BoostedBatteryInfo
46
- from .device_status import GroupInfo, CalibrationInfo, AdvancedFeatures, VectraInfo, CoffeeInfo, MaintenanceInfo, DeviceTimeInfo, MeshPeer, Tank, LunarPhaseInfo
46
+ from .device_status import GroupInfo, CalibrationInfo, AdvancedFeatures, VectraInfo, CoffeeInfo, MaintenanceInfo, DeviceTimeInfo, MeshPeer, Tank, LunarPhaseInfo, MetadataSnapshot
47
47
  from .coap import (
48
48
  CoapRequestType, CoapMethod, CoapResponseCode, CoapResponse,
49
49
  encode_coap_request, decode_coap_response, COAP_OPCODE, COAP_INDICATION_OPCODE,
@@ -56,7 +56,7 @@ from .mesh_address import (
56
56
  )
57
57
  from .relay import RelayedMobiusDevice
58
58
  from .device import (
59
- MobiusDevice, MobiusPump, LightIntensityResult,
59
+ MobiusDevice, MobiusPump, LightIntensityResult, LightPollResult, FullPollResult,
60
60
  SERVICE_GENERAL, CHAR_RX_DATA, CHAR_RX_FINAL, CHAR_TX_DATA, CHAR_TX_FINAL,
61
61
  )
62
62
  from .discovery import (
@@ -64,8 +64,14 @@ from .discovery import (
64
64
  dedupe_by_serial, find_device_by_serial, discover_mesh_peers_via_direct_connect,
65
65
  discover_tank,
66
66
  )
67
+ from .frame import SupportedAttribute
68
+ from .dump import (
69
+ dump_attributes, format_attribute_dump_json, parse_attribute_dump_json,
70
+ format_attribute_dump_text, parse_attribute_dump_text, parse_attribute_dump,
71
+ enrich_attribute_dump,
72
+ )
67
73
 
68
- __version__ = "0.5.0"
74
+ __version__ = "0.7.0"
69
75
 
70
76
  __all__ = [
71
77
  "__version__",
@@ -73,7 +79,7 @@ __all__ = [
73
79
  "crc16", "CRC16_TABLE",
74
80
  # frame
75
81
  "build_frame", "parse_frame", "ParsedFrame",
76
- "encode_get_attribute", "encode_set_attribute", "decode_attribute_response", "AttributeValue",
82
+ "encode_get_attribute", "encode_get_attributes_batch", "encode_set_attribute", "decode_attribute_response", "AttributeValue",
77
83
  "OPGROUP_C2CI_REQUEST", "OPGROUP_C2CI_CONFIRM",
78
84
  "OPGROUP_FSCI_REQUEST", "OPGROUP_FSCI_CONFIRM",
79
85
  "OPGROUP_QOTAP_REQUEST", "OPGROUP_QOTAP_CONFIRM",
@@ -103,7 +109,7 @@ __all__ = [
103
109
  # pump_status
104
110
  "PumpFlowRange", "BatteryBackupInfo", "BoostedBatteryInfo",
105
111
  # device_status
106
- "GroupInfo", "CalibrationInfo", "AdvancedFeatures", "VectraInfo", "CoffeeInfo", "MaintenanceInfo", "DeviceTimeInfo", "MeshPeer", "Tank", "LunarPhaseInfo",
112
+ "GroupInfo", "CalibrationInfo", "AdvancedFeatures", "VectraInfo", "CoffeeInfo", "MaintenanceInfo", "DeviceTimeInfo", "MeshPeer", "Tank", "LunarPhaseInfo", "MetadataSnapshot",
107
113
  "CoapRequestType", "CoapMethod", "CoapResponseCode", "CoapResponse",
108
114
  "encode_coap_request", "decode_coap_response", "COAP_OPCODE", "COAP_INDICATION_OPCODE",
109
115
  "next_coap_token",
@@ -111,10 +117,13 @@ __all__ = [
111
117
  "build_rloc_address", "extract_short_address", "is_short_address_derived",
112
118
  "RelayedMobiusDevice",
113
119
  # device
114
- "MobiusDevice", "MobiusPump", "LightIntensityResult",
120
+ "MobiusDevice", "MobiusPump", "LightIntensityResult", "LightPollResult", "FullPollResult",
115
121
  "SERVICE_GENERAL", "CHAR_RX_DATA", "CHAR_RX_FINAL", "CHAR_TX_DATA", "CHAR_TX_FINAL",
116
122
  # discovery
117
123
  "scan_for_mobius_devices", "scan_for_mobius_devices_with_info", "group_by_pan_id",
118
124
  "dedupe_by_serial", "find_device_by_serial", "discover_mesh_peers_via_direct_connect",
119
125
  "discover_tank",
126
+ "SupportedAttribute", "dump_attributes", "enrich_attribute_dump",
127
+ "format_attribute_dump_json", "parse_attribute_dump_json",
128
+ "format_attribute_dump_text", "parse_attribute_dump_text", "parse_attribute_dump",
120
129
  ]
mobius/cli.py CHANGED
@@ -23,6 +23,7 @@ from .discovery import (
23
23
  find_device_by_serial, discover_mesh_peers_via_direct_connect, discover_tank,
24
24
  )
25
25
  from .schedule import SchedulePoint
26
+ from .dump import dump_attributes, format_attribute_dump_json, parse_attribute_dump, enrich_attribute_dump
26
27
 
27
28
 
28
29
  def _format_flags(point) -> str:
@@ -373,12 +374,93 @@ async def _run(args) -> None:
373
374
  print(f" reboot() FAILED: {e}")
374
375
 
375
376
  if args.set_time_to_now:
376
- print(f"\n=== set_time_to_now() on {args.by_serial!r} (WRITE -- Epoch) ===")
377
- try:
378
- await mdevice.set_time_to_now()
379
- print(f" set_time_to_now() on {args.by_serial!r} succeeded.")
380
- except Exception as e:
381
- print(f" set_time_to_now() FAILED: {e}")
377
+ # Same relay-aware targeting as --reboot above -- see
378
+ # that block's own comment for why this deliberately
379
+ # doesn't fall back to the directly-connected device
380
+ # if relay resolution failed.
381
+ if args.relay_target:
382
+ if relayed is None:
383
+ print(f"\n=== set_time_to_now() on {args.relay_target!r} (via relay) -- "
384
+ f"SKIPPED: relay target could not be resolved above ===")
385
+ time_target = None
386
+ else:
387
+ time_target_label = f"{args.relay_target!r} (via relay through {args.by_serial!r})"
388
+ time_target = relayed
389
+ else:
390
+ time_target_label = f"{args.by_serial!r} (direct)"
391
+ time_target = mdevice
392
+
393
+ if time_target is not None:
394
+ print(f"\n=== set_time_to_now() on {time_target_label} (WRITE -- Epoch) ===")
395
+ try:
396
+ await time_target.set_time_to_now()
397
+ print(f" set_time_to_now() on {time_target_label} succeeded.")
398
+ except Exception as e:
399
+ print(f" set_time_to_now() FAILED: {e}")
400
+
401
+ if args.dump_attributes:
402
+ # Same relay-aware targeting as --reboot above.
403
+ if args.relay_target:
404
+ if relayed is None:
405
+ print(f"\n=== dump_attributes() on {args.relay_target!r} (via relay) -- "
406
+ f"SKIPPED: relay target could not be resolved above ===")
407
+ dump_target = None
408
+ else:
409
+ dump_target_label = f"{args.relay_target!r} (via relay through {args.by_serial!r})"
410
+ dump_target = relayed
411
+ else:
412
+ dump_target_label = f"{args.by_serial!r} (direct)"
413
+ dump_target = mdevice
414
+
415
+ if dump_target is not None:
416
+ print(f"\n=== dump_attributes() on {dump_target_label} -- "
417
+ f"writing to {args.dump_attributes!r} ===")
418
+ print(" Discovering supported attributes...")
419
+ try:
420
+ values = await dump_attributes(dump_target)
421
+ text = format_attribute_dump_json(values)
422
+ with open(args.dump_attributes, "w") as f:
423
+ f.write(text)
424
+ print(f" Wrote {len(values)} attribute(s) to {args.dump_attributes!r}.")
425
+ except Exception as e:
426
+ print(f" dump_attributes() FAILED: {e}")
427
+
428
+ requested_features = {}
429
+ if args.set_local_control_enabled is not None:
430
+ requested_features["local_control_enabled"] = args.set_local_control_enabled == "true"
431
+ if args.set_auto_dim_timeout is not None:
432
+ requested_features["auto_dim_timeout"] = args.set_auto_dim_timeout
433
+ if args.set_max_fan_speed is not None:
434
+ requested_features["max_fan_speed"] = args.set_max_fan_speed
435
+ if args.set_fan_shutdown_enabled is not None:
436
+ requested_features["fan_shutdown_enabled"] = args.set_fan_shutdown_enabled == "true"
437
+
438
+ if requested_features:
439
+ # Same relay-aware targeting as --reboot above.
440
+ if args.relay_target:
441
+ if relayed is None:
442
+ print(f"\n=== set_advanced_features() on {args.relay_target!r} (via relay) -- "
443
+ f"SKIPPED: relay target could not be resolved above ===")
444
+ features_target = None
445
+ else:
446
+ features_target_label = f"{args.relay_target!r} (via relay through {args.by_serial!r})"
447
+ features_target = relayed
448
+ else:
449
+ features_target_label = f"{args.by_serial!r} (direct)"
450
+ features_target = mdevice
451
+
452
+ if features_target is not None:
453
+ print(f"\n=== set_advanced_features({', '.join(f'{k}={v!r}' for k, v in requested_features.items())}) "
454
+ f"on {features_target_label} (WRITE) ===")
455
+ try:
456
+ result = await features_target.set_advanced_features(**requested_features)
457
+ for field, error in result.items():
458
+ if error is None:
459
+ print(f" {field}: succeeded.")
460
+ else:
461
+ print(f" {field}: FAILED: {error}")
462
+ except Exception as e:
463
+ print(f" set_advanced_features() FAILED entirely: {e}")
382
464
 
383
465
  except Exception as e:
384
466
  print(" failed to connect/read:", e)
@@ -620,9 +702,12 @@ def main() -> None:
620
702
  parser.add_argument("--set-time-to-now", action="store_true",
621
703
  help="Only usable with --by-serial. A WRITE -- use on hardware you're "
622
704
  "prepared to have its clock changed on. Calls set_time_to_now() "
623
- "on the directly-connected device (Epoch, reserved-byte group=1 "
705
+ "on the target device (Epoch, reserved-byte group=1 "
624
706
  "-- required for the write to be accepted at all, confirmed "
625
- "against real hardware). See that method's own docstring, and "
707
+ "against real hardware). If --relay-target is also given, sets "
708
+ "the time on THAT device via relay through the connected one "
709
+ "instead, same targeting as --reboot below. See that method's own "
710
+ "docstring, and "
626
711
  "documentation/09-thread-coap-relay.md, for what's confirmed "
627
712
  "about this vs the rest of the mesh.")
628
713
  parser.add_argument("--reboot", action="store_true",
@@ -645,7 +730,78 @@ def main() -> None:
645
730
  "once in that case, so you can later keep only one connection "
646
731
  "open and relay to the rest. See "
647
732
  "documentation/09-thread-coap-relay.md.")
733
+ parser.add_argument("--dump-attributes", default=None, metavar="FILE",
734
+ help="Only usable with --by-serial. Discovers every attribute the "
735
+ "target device reports supporting -- the relay target if "
736
+ "--relay-target is also given, otherwise the directly-connected "
737
+ "device itself (via "
738
+ "get_supported_attributes(), confirmed against real hardware), "
739
+ "reads every one, and writes the result to FILE as enriched JSON "
740
+ "-- resolved attribute names and decoded values where this "
741
+ "library has a confirmed decoder, generic best-effort numeric "
742
+ "interpretations otherwise. NOT the app's own plain-text format "
743
+ "(see format_attribute_dump_text() if you specifically need "
744
+ "that, e.g. to compare against a dump shared directly from the "
745
+ "app). Read-only -- doesn't write anything to the device itself.")
746
+ parser.add_argument("--set-local-control-enabled", default=None, choices=["true", "false"],
747
+ help="Only usable with --by-serial. A WRITE -- use on hardware you're "
748
+ "prepared to have this setting changed on. VorTech-relevant (the "
749
+ "app's own \"Local Control\" setting) -- see set_advanced_features() "
750
+ "for the confirmed wire format. Combinable with the other "
751
+ "--set-*-enabled/--set-auto-dim-timeout/--set-max-fan-speed flags "
752
+ "below -- all requested fields are sent in one call, whichever of "
753
+ "them the target device actually supports.")
754
+ parser.add_argument("--set-auto-dim-timeout", default=None, type=int, metavar="SECONDS",
755
+ help="Only usable with --by-serial. A WRITE. VorTech-relevant (the "
756
+ "app's own \"Led Auto Dim\" setting). The app's own UI only ever "
757
+ "offers 0/30/60/300/600/1800/3600 (0 = \"Always On\") as a fixed "
758
+ "chooser -- this library does NOT restrict to those, since that's "
759
+ "a UI-level choice, not a confirmed protocol-level one; validate "
760
+ "against that list yourself first if you want to match the app's "
761
+ "own restricted choices exactly.")
762
+ parser.add_argument("--set-max-fan-speed", default=None, type=float, metavar="PERCENT",
763
+ help="Only usable with --by-serial. A WRITE. Radion-relevant (the "
764
+ "app's own \"Max Fan Speed\" setting), 0-100 percent -- 100 "
765
+ "encodes as the raw attribute's own -1 (0xFFFF) sentinel, "
766
+ "confirmed matching the app's own \"100%%\" preset exactly (see "
767
+ "set_advanced_features()'s own docstring). Same non-restriction "
768
+ "note as --set-auto-dim-timeout above -- the app's own presets "
769
+ "are 10/20/40/60/80/100, not enforced here.")
770
+ parser.add_argument("--set-fan-shutdown-enabled", default=None, choices=["true", "false"],
771
+ help="Only usable with --by-serial. A WRITE. Radion-relevant (the "
772
+ "app's own \"Fan Shutdown\" setting).")
773
+ parser.add_argument("--parse-dump", default=None, metavar="FILE",
774
+ help="Standalone -- no device connection, no scanning, ignores every "
775
+ "other flag. Parses an existing attribute-dump file (this "
776
+ "library's own JSON from --dump-attributes above, or the app's "
777
+ "own plain-text format -- auto-detected either way) and prints "
778
+ "its contents enriched: resolved attribute names and decoded "
779
+ "values where known, for offline inspection/debugging without "
780
+ "the device present.")
648
781
  args = parser.parse_args()
782
+
783
+ if args.parse_dump:
784
+ # Standalone: no device, no event loop needed at all -- deliberately
785
+ # handled here, before asyncio.run(_run(args)), rather than as
786
+ # another branch inside _run(), since every other flag assumes a
787
+ # real connection is at least attempted.
788
+ with open(args.parse_dump) as f:
789
+ text = f.read()
790
+ values = parse_attribute_dump(text)
791
+ enriched = enrich_attribute_dump(values)
792
+ print(f"=== Parsed {len(enriched)} attribute(s) from {args.parse_dump!r} ===")
793
+ for entry in enriched:
794
+ label = f"{entry['attr_id']} ({entry['name']})" if entry["name"] else str(entry["attr_id"])
795
+ print(f"{label}: {entry['index']}")
796
+ for element in entry["elements"]:
797
+ if element["decoded"] is not None:
798
+ print(f"\t{element['decoded']} [raw: {element['raw_hex']}]")
799
+ else:
800
+ interp = ", ".join(f"{k}={v}" for k, v in element["interpretations"].items())
801
+ suffix = f" ({interp})" if interp else ""
802
+ print(f"\t{element['raw_hex']}{suffix}")
803
+ return
804
+
649
805
  asyncio.run(_run(args))
650
806
 
651
807
 
mobius/coap.py CHANGED
@@ -24,14 +24,14 @@ Get/Set-attribute opcodes this library already uses. Carried inside the
24
24
  same outer FSCI frame (see mobius.frame) as everything else -- this is
25
25
  not a separate transport, just a different C2CI opcode.
26
26
 
27
- **Request** (`encode_coap_request()`): confirmed via `CoapRequest`'s first
28
- constructor --
27
+ **Request** (`encode_coap_request()`): confirmed via reverse engineering
28
+ the app's own equivalent request-construction logic --
29
29
 
30
30
  IPv6(16 bytes, REVERSED) + token(4) + method(2) + payload_length(2)
31
31
  + request_type(1) + payload
32
32
 
33
- **Response** (`decode_coap_response()`): confirmed via `CoapResponse`'s
34
- constructor --
33
+ **Response** (`decode_coap_response()`): confirmed via reverse
34
+ engineering the app's own equivalent response-construction logic --
35
35
 
36
36
  IPv6(16 bytes, NOT reversed) + token(4) + response_code(2)
37
37
  + payload_length(2) + request_type(1) + payload
@@ -41,7 +41,8 @@ doesn't) is easy to get backwards -- confirmed directly from the
41
41
  decompiled source, not a guess, so implemented exactly as traced.
42
42
 
43
43
  **The payload is just an existing FSCI request/response's raw bytes.**
44
- `CoapFsciRequest` in the decompile literally wraps `request.getData(true)`
44
+ Confirmed via reverse engineering: the app's own CoAP request wraps an
45
+ existing FSCI request's own raw data directly
45
46
  -- meaning none of this library's existing Get/Set attribute encoding
46
47
  needs reimplementing. This module only handles the CoAP envelope; the
47
48
  payload is built with mobius.frame's existing encode_get_attribute() /
@@ -70,8 +71,8 @@ lookups for these specific obscure constants). Rather than guess, this
70
71
  module only defines the CONFIRMED response codes (all literal, or
71
72
  independently cross-referenced) in CoapResponseCode -- an unrecognized
72
73
  code decodes to a plain int, not a guessed enum member, matching the real
73
- app's own graceful-fallback behavior (`getResponseCode()` returns
74
- `coap_Empty` for anything it doesn't recognize, rather than crashing).
74
+ app's own graceful-fallback behavior for an unrecognized response code
75
+ (falling back to `coap_Empty` rather than crashing).
75
76
  `Content` (matching the real "2.05 Content" success response to a GET,
76
77
  which we'll see constantly in practice) is the one value here confirmed
77
78
  only by strong pattern inference, not direct source confirmation -- flagged
@@ -97,9 +98,9 @@ COAP_OPCODE = 25
97
98
  # ack the app itself explicitly discards (see mobius.relay's module
98
99
  # docstring for the full trace) -- opcode 26 is a genuinely distinct
99
100
  # "indication," and matching it back to the request that triggered it is
100
- # done purely by CoAP token (confirmed via reverse engineering:
101
- # coapRequest.getToken() == coapResponse.getToken()), never by the outer
102
- # FSCI message ID.
101
+ # done purely by CoAP token (confirmed via reverse engineering: the
102
+ # request's own token must match the response's own token), never by
103
+ # the outer FSCI message ID.
103
104
  COAP_INDICATION_OPCODE = 26
104
105
 
105
106
 
@@ -192,7 +193,8 @@ def encode_coap_request(
192
193
  `target_address` must be exactly 16 bytes (a full IPv6 address -- see
193
194
  mesh_local_address() for how to construct one for a specific device).
194
195
 
195
- Confirmed wire format (CoapRequest's first constructor):
196
+ Confirmed wire format (via reverse engineering the app's own
197
+ equivalent request-construction logic):
196
198
  IPv6(16, REVERSED) + token(4) + method(2) + payload_length(2)
197
199
  + request_type(1) + payload.
198
200
  """
@@ -213,9 +215,11 @@ def decode_coap_response(data: bytes) -> Optional[CoapResponse]:
213
215
  """
214
216
  Parses a C2CI-opcode-25 response payload. Returns None if the data is
215
217
  too short to be a valid CoAP response (confirmed minimum: 25 bytes
216
- before any payload, matching CoapResponse.validate()'s own check).
218
+ before any payload, matching the app's own equivalent validation
219
+ check).
217
220
 
218
- Confirmed wire format (CoapResponse's constructor):
221
+ Confirmed wire format (via reverse engineering the app's own
222
+ response-parsing constructor):
219
223
  IPv6(16, NOT reversed) + token(4) + response_code(2) + payload_length(2)
220
224
  + request_type(1) + payload.
221
225
  """
@@ -234,7 +238,7 @@ def decode_coap_response(data: bytes) -> Optional[CoapResponse]:
234
238
  try:
235
239
  request_type = CoapRequestType(data[24])
236
240
  except ValueError:
237
- request_type = CoapRequestType.Con # matches Coap.RequestType.getRequestType()'s own fallback
241
+ request_type = CoapRequestType.Con # matches the app's own confirmed fallback behavior
238
242
 
239
243
  payload = data[25:25 + payload_length]
240
244