python-mobius 0.4.4__py3-none-any.whl → 0.6.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,
@@ -35,7 +35,7 @@ from .schedule import (
35
35
  LightPrimitive, SchedulePoint, interpolate_light_schedule,
36
36
  PumpPrimitiveValue, PumpSchedulePoint, get_active_pump_block,
37
37
  )
38
- from .manufacturer import MOBIUS_COMPANY_ID, MobiusAdvertisement, parse_manufacturer_data
38
+ from .manufacturer import MOBIUS_COMPANY_ID_ECOTECH, MOBIUS_COMPANY_ID_AQUAILLUMINATION, MOBIUS_COMPANY_IDS, MobiusAdvertisement, parse_manufacturer_data
39
39
  from .modifiers import (
40
40
  AcclimationInfo, is_night_segment,
41
41
  julian_day_from_date, lunar_days_into_phase, lunar_percent_reduction,
@@ -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, MaintenanceInfo, DeviceTimeInfo, MeshPeer, Tank
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,
@@ -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.4.4"
74
+ __version__ = "0.6.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",
@@ -93,7 +99,7 @@ __all__ = [
93
99
  "LightPrimitive", "SchedulePoint", "interpolate_light_schedule",
94
100
  "PumpPrimitiveValue", "PumpSchedulePoint", "get_active_pump_block",
95
101
  # manufacturer
96
- "MOBIUS_COMPANY_ID", "MobiusAdvertisement", "parse_manufacturer_data",
102
+ "MOBIUS_COMPANY_ID_ECOTECH", "MOBIUS_COMPANY_ID_AQUAILLUMINATION", "MOBIUS_COMPANY_IDS", "MobiusAdvertisement", "parse_manufacturer_data",
97
103
  # modifiers
98
104
  "AcclimationInfo", "is_night_segment",
99
105
  "julian_day_from_date", "lunar_days_into_phase", "lunar_percent_reduction",
@@ -103,7 +109,7 @@ __all__ = [
103
109
  # pump_status
104
110
  "PumpFlowRange", "BatteryBackupInfo", "BoostedBatteryInfo",
105
111
  # device_status
106
- "GroupInfo", "CalibrationInfo", "MaintenanceInfo", "DeviceTimeInfo", "MeshPeer", "Tank",
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",
@@ -117,4 +123,7 @@ __all__ = [
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:
@@ -60,10 +61,27 @@ async def _dump_schedule(device: MobiusDevice, support: str) -> None:
60
61
  print(f" {_format_time(point.time_minutes)}{_format_flags(point)}: {channels}")
61
62
 
62
63
  lunar_enabled = await device.get_lunar_enabled()
64
+ print(f" lunar_enabled: {lunar_enabled}")
65
+
66
+ # The device's own firmware-computed lunar state -- see
67
+ # LunarPhaseInfo's own docstring in device_status.py for the
68
+ # confirmation and what this is useful for. Compare against
69
+ # "current_intensities"/"light_diagnostics" in the summary
70
+ # output above (NOT re-fetched here -- get_device_summary()
71
+ # already called get_current_light_intensities() once; a
72
+ # second call here would just be a redundant device read of
73
+ # the exact same thing). A real failure is shown directly
74
+ # (get_lunar_phase_info() no longer swallows it) rather than
75
+ # printing an indistinguishable "None".
76
+ try:
77
+ lunar_phase_info = await device.get_lunar_phase_info()
78
+ print(f" lunar_phase_info: {lunar_phase_info}")
79
+ except Exception as e:
80
+ print(f" lunar_phase_info: read failed: {e}")
81
+
63
82
  insolation_active = await device.get_insolation_enabled()
64
83
  schedule_intensity = await device.get_schedule_intensity()
65
84
  acclimation = await device.get_acclimation_info()
66
- print(f" lunar_enabled: {lunar_enabled}")
67
85
  print(f" insolation_active: {insolation_active}")
68
86
  print(f" schedule_intensity: {schedule_intensity}")
69
87
  print(f" acclimation: {acclimation}")
@@ -241,7 +259,14 @@ async def _run(args) -> None:
241
259
  device, info = found
242
260
  print(f" currently at {device.address} model={info.model.name if info.model else info.model_raw}")
243
261
  try:
244
- async with MobiusDevice(serial=args.by_serial, adapter=args.adapter,
262
+ # device= is the exact BLEDevice found_device_by_serial() just
263
+ # located above -- passing it avoids a second, independent scan
264
+ # inside connect() (see MobiusDevice.__init__()'s own docstring:
265
+ # re-scanning per device can cause BlueZ to drop it between
266
+ # "found" and "connect", the exact failure this fixes). serial=
267
+ # is still passed alongside it, for this same instance's own
268
+ # reconnect logic if the connection drops later in this run.
269
+ async with MobiusDevice(device=device, serial=args.by_serial, adapter=args.adapter,
245
270
  connect_timeout=args.connect_timeout) as mdevice:
246
271
  summary = await mdevice.get_device_summary()
247
272
  for k, v in summary.items():
@@ -249,6 +274,7 @@ async def _run(args) -> None:
249
274
  if args.dump_schedule:
250
275
  await _dump_schedule(mdevice, summary.get("support", ""))
251
276
 
277
+ relayed = None # set below only if --relay-target resolves successfully
252
278
  if args.dump_mesh_peers or args.relay_target:
253
279
  print("\n=== Thread mesh peer discovery ===")
254
280
  peers = await _debug_mesh_discovery(mdevice)
@@ -314,6 +340,128 @@ async def _run(args) -> None:
314
340
  f"`--by-serial {args.relay_target}` run to confirm they match.")
315
341
  except Exception as e:
316
342
  print(f" relay failed: {e}")
343
+
344
+ if args.reboot:
345
+ # If --relay-target was also given, reboot THAT device
346
+ # via relay through the connected one, instead of the
347
+ # directly-connected device itself -- lets both the
348
+ # direct and relayed write paths get tested with the
349
+ # same flag, without a separate one. If relay resolution
350
+ # above failed, this deliberately does NOT fall back to
351
+ # rebooting the directly-connected device instead --
352
+ # that would silently reboot a device the user didn't
353
+ # actually ask to reboot.
354
+ if args.relay_target:
355
+ if relayed is None:
356
+ print(f"\n=== reboot() on {args.relay_target!r} (via relay) -- "
357
+ f"SKIPPED: relay target could not be resolved above ===")
358
+ reboot_target = None
359
+ else:
360
+ reboot_target_label = f"{args.relay_target!r} (via relay through {args.by_serial!r})"
361
+ reboot_target = relayed
362
+ else:
363
+ reboot_target_label = f"{args.by_serial!r} (direct)"
364
+ reboot_target = mdevice
365
+
366
+ if reboot_target is not None:
367
+ print(f"\n=== reboot() on {reboot_target_label} (WRITE -- Reset, soft) ===")
368
+ try:
369
+ await reboot_target.reboot()
370
+ print(f" reboot() on {reboot_target_label} succeeded (device "
371
+ f"acknowledged the request -- it may take a few seconds to "
372
+ f"actually finish rebooting and reconnect).")
373
+ except Exception as e:
374
+ print(f" reboot() FAILED: {e}")
375
+
376
+ if args.set_time_to_now:
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}")
464
+
317
465
  except Exception as e:
318
466
  print(" failed to connect/read:", e)
319
467
  return
@@ -365,7 +513,10 @@ async def _run(args) -> None:
365
513
  device, info = connectable[0]
366
514
  print(f"\n=== Network addresses (connecting to {info.serial} to ask) ===")
367
515
  try:
368
- async with MobiusDevice(serial=info.serial, adapter=args.adapter,
516
+ # device= already found above via the scan feeding `connectable`
517
+ # -- see the --by-serial path's own comment for why this avoids
518
+ # a redundant, race-prone second scan inside connect().
519
+ async with MobiusDevice(device=device, serial=info.serial, adapter=args.adapter,
369
520
  connect_timeout=args.connect_timeout) as mdevice:
370
521
  peers = await _debug_mesh_discovery(mdevice)
371
522
  except Exception as e:
@@ -398,7 +549,10 @@ async def _run(args) -> None:
398
549
  device, info = connectable[0]
399
550
  print(f" pan_id {label} (connecting through {info.serial})...")
400
551
  try:
401
- async with MobiusDevice(serial=info.serial, adapter=args.adapter,
552
+ # device= already found above -- see the --by-serial path's
553
+ # own comment for why this avoids a redundant, race-prone
554
+ # second scan inside connect().
555
+ async with MobiusDevice(device=device, serial=info.serial, adapter=args.adapter,
402
556
  connect_timeout=args.connect_timeout) as mdevice:
403
557
  tank = await discover_tank(mdevice)
404
558
  except Exception as e:
@@ -508,8 +662,11 @@ def main() -> None:
508
662
  "(every point's time, flags, and per-channel/mode values) -- not "
509
663
  "just the interpolated 'current' value get_device_summary() shows. "
510
664
  "For lights, also prints lunar/insolation/schedule-intensity/"
511
- "acclimation state. Useful for diagnosing a mismatch against the "
512
- "app's own displayed value.")
665
+ "acclimation state, PLUS the device's own firmware-computed lunar "
666
+ "state (LunarPhasesCurrentDay/CurrentScalar, confirmed against real "
667
+ "hardware -- see LunarPhaseInfo's own docstring) -- compare against "
668
+ "\"current_intensities\"/\"light_diagnostics\" in the summary output "
669
+ "above for this library's own calculated value.")
513
670
  parser.add_argument("--dump-mesh-peers", action="store_true",
514
671
  help="Only usable with --by-serial. After connecting, calls "
515
672
  "discover_mesh_peers_auto() on it and prints every other device "
@@ -542,6 +699,26 @@ def main() -> None:
542
699
  "confirmed-meaningless opcode-25 ack and the real opcode-26 "
543
700
  "indication, if it arrives). Useful for diagnosing a relay that "
544
701
  "times out or returns unexpected data.")
702
+ parser.add_argument("--set-time-to-now", action="store_true",
703
+ help="Only usable with --by-serial. A WRITE -- use on hardware you're "
704
+ "prepared to have its clock changed on. Calls set_time_to_now() "
705
+ "on the target device (Epoch, reserved-byte group=1 "
706
+ "-- required for the write to be accepted at all, confirmed "
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 "
711
+ "documentation/09-thread-coap-relay.md, for what's confirmed "
712
+ "about this vs the rest of the mesh.")
713
+ parser.add_argument("--reboot", action="store_true",
714
+ help="Only usable with --by-serial. A WRITE -- use on hardware you're "
715
+ "prepared to have reboot. Soft-reboots the directly-connected "
716
+ "device (Reset, ResetType.Soft) -- confirmed via the app's own "
717
+ "source to be exactly its own \"Restart\" button. If "
718
+ "--relay-target is also given, reboots THAT device via relay "
719
+ "through the connected one instead, to test the relayed write "
720
+ "path specifically -- see reboot()'s own docstring for what's "
721
+ "confirmed about this vs a direct connection.")
545
722
  parser.add_argument("--build-peer-map", action="store_true",
546
723
  help="Instead of full device summaries, scan for devices then "
547
724
  "briefly connect to EACH one in turn to build a mesh peer map "
@@ -553,7 +730,78 @@ def main() -> None:
553
730
  "once in that case, so you can later keep only one connection "
554
731
  "open and relay to the rest. See "
555
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.")
556
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
+
557
805
  asyncio.run(_run(args))
558
806
 
559
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