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 +16 -7
- mobius/cli.py +254 -6
- mobius/coap.py +18 -14
- mobius/constants.py +271 -63
- mobius/device.py +1593 -126
- mobius/device_status.py +569 -4
- mobius/discovery.py +16 -9
- mobius/dump.py +378 -0
- mobius/frame.py +98 -2
- mobius/manufacturer.py +36 -16
- mobius/mesh_address.py +2 -2
- mobius/modifiers.py +13 -10
- mobius/power.py +9 -8
- mobius/relay.py +98 -6
- mobius/schedule.py +5 -6
- {python_mobius-0.4.4.dist-info → python_mobius-0.6.0.dist-info}/METADATA +43 -8
- python_mobius-0.6.0.dist-info/RECORD +22 -0
- python_mobius-0.4.4.dist-info/RECORD +0 -21
- {python_mobius-0.4.4.dist-info → python_mobius-0.6.0.dist-info}/WHEEL +0 -0
- {python_mobius-0.4.4.dist-info → python_mobius-0.6.0.dist-info}/entry_points.txt +0 -0
- {python_mobius-0.4.4.dist-info → python_mobius-0.6.0.dist-info}/licenses/LICENSE +0 -0
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
|
|
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.
|
|
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
|
-
"
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
512
|
-
"
|
|
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
|
|
28
|
-
|
|
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
|
|
34
|
-
|
|
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
|
-
|
|
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
|
|
74
|
-
`coap_Empty`
|
|
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
|
-
#
|
|
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 (
|
|
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
|
|
218
|
+
before any payload, matching the app's own equivalent validation
|
|
219
|
+
check).
|
|
217
220
|
|
|
218
|
-
Confirmed wire format (
|
|
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
|
|
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
|
|