python-mobius 0.11.0__tar.gz → 0.12.0__tar.gz

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.
Files changed (112) hide show
  1. {python_mobius-0.11.0 → python_mobius-0.12.0}/CHANGELOG.md +43 -0
  2. {python_mobius-0.11.0 → python_mobius-0.12.0}/PKG-INFO +1 -1
  3. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/16-python-library.md +8 -6
  4. {python_mobius-0.11.0 → python_mobius-0.12.0}/pyproject.toml +1 -1
  5. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/__init__.py +5 -5
  6. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/cli.py +85 -83
  7. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/device.py +559 -539
  8. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/device_status.py +19 -7
  9. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/discovery.py +44 -29
  10. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/dump.py +12 -17
  11. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/modifiers.py +0 -2
  12. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/relay.py +14 -48
  13. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/scenes.py +4 -3
  14. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/schedule.py +31 -19
  15. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/timezone.py +2 -6
  16. python_mobius-0.12.0/tests/fake_reads.py +20 -0
  17. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_attributes_batch.py +4 -3
  18. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_bluetooth_only.py +1 -1
  19. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_checksums.py +1 -1
  20. python_mobius-0.12.0/tests/test_cli_diagnostics.py +31 -0
  21. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_cli_group_targets.py +11 -12
  22. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_cli_mob_file.py +3 -3
  23. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_coap_indication_handling.py +5 -18
  24. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_connection_cleanup.py +6 -4
  25. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_device_control.py +3 -2
  26. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_device_summary.py +64 -16
  27. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_discovery_company_ids.py +15 -0
  28. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_dump.py +16 -3
  29. python_mobius-0.12.0/tests/test_enum_decoding.py +37 -0
  30. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_firmware_updates.py +2 -2
  31. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_firmware_versions.py +75 -38
  32. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_full_poll_batch.py +105 -26
  33. python_mobius-0.12.0/tests/test_get_attribute.py +50 -0
  34. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_hyperdrive_device.py +17 -15
  35. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_insolation_detection.py +7 -5
  36. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_light_intensity_branching.py +13 -11
  37. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_light_poll_batch.py +5 -3
  38. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_mesh_discovery.py +51 -1
  39. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_metadata_batch.py +38 -49
  40. python_mobius-0.12.0/tests/test_model_decoding.py +39 -0
  41. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_networked_thread_devices.py +1 -1
  42. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_new_diagnostic_attributes.py +10 -15
  43. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_pump_telemetry.py +40 -7
  44. python_mobius-0.12.0/tests/test_received_frame_logging.py +33 -0
  45. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_relay.py +6 -9
  46. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_schedule_intensity_scalar.py +8 -6
  47. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_serial_identity.py +12 -8
  48. python_mobius-0.12.0/tests/test_unknown_light_channels.py +33 -0
  49. python_mobius-0.11.0/tests/test_frame_sniffer.py +0 -88
  50. {python_mobius-0.11.0 → python_mobius-0.12.0}/.forgejo/scripts/extract_changelog_section.py +0 -0
  51. {python_mobius-0.11.0 → python_mobius-0.12.0}/.forgejo/workflows/release.yml +0 -0
  52. {python_mobius-0.11.0 → python_mobius-0.12.0}/.gitignore +0 -0
  53. {python_mobius-0.11.0 → python_mobius-0.12.0}/LICENSE +0 -0
  54. {python_mobius-0.11.0 → python_mobius-0.12.0}/README.md +0 -0
  55. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/00-overview.md +0 -0
  56. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/01-ble-transport.md +0 -0
  57. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/02-framing-and-crc.md +0 -0
  58. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/03-attributes-and-opcodes.md +0 -0
  59. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/04-device-identity.md +0 -0
  60. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/05-scenes.md +0 -0
  61. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/06-light-schedule.md +0 -0
  62. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/07-pump-schedule.md +0 -0
  63. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/08-manufacturer-data.md +0 -0
  64. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/09-thread-coap-relay.md +0 -0
  65. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/10-known-gaps-and-open-questions.md +0 -0
  66. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/11-device-status-attributes.md +0 -0
  67. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/12-device-identity-and-address-stability.md +0 -0
  68. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/13-attribute-dump.md +0 -0
  69. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/14-attribute-reference.md +0 -0
  70. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/15-mob-template-format.md +0 -0
  71. {python_mobius-0.11.0 → python_mobius-0.12.0}/documentation/17-configuration-checksums.md +0 -0
  72. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/checksums.py +0 -0
  73. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/coap.py +0 -0
  74. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/constants.py +0 -0
  75. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/crc.py +0 -0
  76. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/firmware.py +0 -0
  77. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/frame.py +0 -0
  78. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/manufacturer.py +0 -0
  79. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/mesh_address.py +0 -0
  80. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/mob.py +0 -0
  81. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/power.py +0 -0
  82. {python_mobius-0.11.0 → python_mobius-0.12.0}/src/mobius/pump_status.py +0 -0
  83. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_battery_settings.py +0 -0
  84. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_cli_schedule_file.py +0 -0
  85. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_coap.py +0 -0
  86. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_configuration_cache.py +0 -0
  87. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_device_status.py +0 -0
  88. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_device_time.py +0 -0
  89. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_extra_physical_values.py +0 -0
  90. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_fan_thresholds.py +0 -0
  91. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_frame.py +0 -0
  92. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_group_bitmap.py +0 -0
  93. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_light_schedule.py +0 -0
  94. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_manufacturer.py +0 -0
  95. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_manufacturer_lookup.py +0 -0
  96. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_mesh_address.py +0 -0
  97. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_mesh_discovery_direct_connect.py +0 -0
  98. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_mob.py +0 -0
  99. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_modifiers.py +0 -0
  100. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_power.py +0 -0
  101. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_pump_param_ranges.py +0 -0
  102. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_pump_reverse.py +0 -0
  103. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_pump_schedule.py +0 -0
  104. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_pump_status.py +0 -0
  105. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_pump_supported_modes.py +0 -0
  106. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_scenes.py +0 -0
  107. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_schedule_file_format.py +0 -0
  108. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_serial_decoding.py +0 -0
  109. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_shared_helpers.py +0 -0
  110. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_supported_attributes.py +0 -0
  111. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_time_zone.py +0 -0
  112. {python_mobius-0.11.0 → python_mobius-0.12.0}/tests/test_write_size.py +0 -0
@@ -1,5 +1,48 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.12.0
4
+
5
+ - Fixed `get_attribute()` keeping only the first block of a split reply
6
+ (schedules, scenes and mesh peer lists could come back incomplete).
7
+ - `get_attribute_raw_all()` is now `get_attribute_blocks()`;
8
+ `get_attribute_raw()` and `get_attribute_all()` are removed.
9
+ - Unknown PrimitiveType and OperationState values decode to `None` instead
10
+ of raising; `find_device_by_serial()` stops scanning once it's seen.
11
+ - Removed the `MobiusPump` alias, `wait_for_coap_response()` and the unused
12
+ `which` argument of `get_lunar_enabled()`/`get_insolation_enabled()`.
13
+ - Relay debugging uses debug logging (`mobius.relay`, `mobius.device`)
14
+ instead of `debug=True` prints and the `on_any_frame` hook.
15
+ - A `MeshPeer` can be built from a serial and address alone.
16
+ - When batching fails, the same attributes are read one at a time and
17
+ decoded the same way (no more double reads); an unreachable device raises.
18
+ - `get_current_light_intensities()` uses the light poll's calculation;
19
+ decoders of single-value attributes take one value.
20
+ - `get_full_poll_batch()` works without `primitive` (it's read first) and
21
+ for any device: only lights and pumps get their type-specific data.
22
+ - The full poll includes Vectra settings and the main firmware version
23
+ (`main_firmware_version()`).
24
+ - `mobius-scan --dump-diagnostics` prints the diagnostic attributes.
25
+ - Model ids read from a device are unsigned, as in advertisements (the
26
+ development boards, 61441-61445, decoded as unknown).
27
+ - Mesh peer lists are decoded by `decode_networked_thread_devices()` /
28
+ `decode_mesh_peer_arrays()`, shared with `mobius-scan`; `discover_tank()`
29
+ leaves out its own entry when its serial can't be read.
30
+ - `decode_device_info()` takes the decoded PrimitiveType
31
+ (`decode_primitive_type()`) instead of its raw bytes.
32
+ - `get_pump_telemetry()` is one batched read (one at a time as a fallback),
33
+ decoded like the full poll's (`decode_pump_telemetry_from_batch()`).
34
+ - The poll no longer requests ErrorState twice.
35
+ - `get_device_summary()` is the full poll plus one batch (it read each
36
+ attribute separately, some twice); a pump's summary has no
37
+ `schedule_error`. `LightPollResult` includes the acclimation ramp.
38
+ - One attribute at a time, a schedule or scene list that can't be read no
39
+ longer fails the whole poll.
40
+ - Light channel ids that aren't a `VisualID` are kept in
41
+ `LightPrimitive.unknown_channels` (they all became `VisualID.Unknown`, and
42
+ were written back as channel 0).
43
+ - `decode_active_scene()` accepts `None`; attribute dumps decode ErrorState
44
+ as the device does (16-bit).
45
+
3
46
  ## 0.11.0
4
47
 
5
48
  - Added firmware update checks (`firmware.py`): the images the app checks
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-mobius
3
- Version: 0.11.0
3
+ Version: 0.12.0
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
@@ -43,8 +43,8 @@ async with MobiusDevice(serial="00000000000002") as device:
43
43
 
44
44
  ## Reading and writing
45
45
 
46
- Low level: `get_attribute()`, `get_attribute_all()` (all blocks merged),
47
- `get_attribute_raw_all()` (blocks with their indexes),
46
+ Low level: `get_attribute()` (values of every block),
47
+ `get_attribute_blocks()` (blocks with their indexes),
48
48
  `get_attributes_batch()`, `get_supported_attributes()`, `set_attribute()`,
49
49
  `set_attribute_multi()`. Every read that fails raises; the higher-level
50
50
  getters return `None` or an empty result for unsupported attributes.
@@ -83,12 +83,13 @@ fails (`used_batch` on the result says which path ran):
83
83
  |---|---|
84
84
  | `get_metadata_batch()` | Advanced features, calibration, firmware, hardware, channels, error state, clock |
85
85
  | `get_light_poll_batch()` | A light's schedule and every input of the intensity computation |
86
- | `get_full_poll_batch(primitive, model)` | Identity, metadata, the light or pump state (including battery settings and operation mode), and scenes |
86
+ | `get_full_poll_batch()` | Identity, metadata, the light or pump state (including battery settings, operation mode and Vectra settings), and scenes |
87
87
 
88
88
  Pass `supported_attribute_ids` (read once with
89
89
  `get_supported_attributes()`) to skip listing attributes on every poll.
90
- `get_full_poll_batch()` needs the `PrimitiveType` and `Model` from a first
91
- `get_device_info()`; after that one request per poll is enough.
90
+ `get_full_poll_batch()` reads the `PrimitiveType` first when it isn't
91
+ passed; pass the result's `primitive` and `model` to later polls and one
92
+ request per poll is enough.
92
93
 
93
94
  Pass a `ConfigurationCache` (one per device, kept between polls) as
94
95
  `get_full_poll_batch(cache=...)` to read the scenes and the schedule only
@@ -157,9 +158,10 @@ With `--by-serial SERIAL` (connects to one device):
157
158
  | Option | Does |
158
159
  |---|---|
159
160
  | `--dump-schedule`, `--dump-scenes` | Print the schedule and the scene slots |
161
+ | `--dump-diagnostics` | Print the diagnostic attributes (clock sync, Thread, BLE, FSCI, ...) |
160
162
  | `--dump-mesh-peers` | Mesh discovery, step by step |
161
163
  | `--relay-target SERIAL` | Reach another device through this one; the options below then act on it. A Bluetooth-only device is connected directly instead ([09](./09-thread-coap-relay.md#bluetooth-only-devices)) |
162
- | `--debug-relay` | Print every layer of each relayed request |
164
+ | `--debug-relay` | Log every layer of each relayed request and every received frame |
163
165
  | `--set-time-to-now`, `--reboot` | Commands |
164
166
  | `--set-local-control-enabled`, `--set-auto-dim-timeout`, `--set-max-fan-speed`, `--set-fan-shutdown-enabled` | Advanced features |
165
167
  | `--set-battery-backup-speed`, `--set-boosted-battery-power`, `--set-boosted-battery-on-time`, `--set-boosted-battery-off-time` | Pump battery settings |
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-mobius"
7
- version = "0.11.0"
7
+ version = "0.12.0"
8
8
  description = "Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)"
9
9
  readme = "README.md"
10
10
  license = { text = "GPL-2.0-only" }
@@ -64,14 +64,14 @@ from .mesh_address import (
64
64
  is_short_address_derived, format_mesh_address,
65
65
  )
66
66
  from .relay import RelayedMobiusDevice
67
- from .device import firmware_label
67
+ from .device import firmware_label, main_firmware_version
68
68
  from .firmware import (
69
69
  FIRMWARE_CLOUD_HOST, ImageId, FirmwareImage, installable_images, firmware_images,
70
70
  firmware_check_url, AvailableFirmware, FirmwareCheck, parse_firmware_check,
71
71
  ChangelogEntry, Changelog, parse_changelog, version_tuple,
72
72
  )
73
73
  from .device import (
74
- MobiusDevice, MobiusPump, LightIntensityResult, LightPollResult, FullPollResult,
74
+ MobiusDevice, LightIntensityResult, LightPollResult, FullPollResult,
75
75
  schedule_capacity, min_schedule_capacity, tank_group,
76
76
  SERVICE_GENERAL, CHAR_RX_DATA, CHAR_RX_FINAL, CHAR_TX_DATA, CHAR_TX_FINAL,
77
77
  )
@@ -93,7 +93,7 @@ from .mob import (
93
93
  MAX_PUMP_PRIMITIVE_SIZE,
94
94
  )
95
95
 
96
- __version__ = "0.11.0"
96
+ __version__ = "0.12.0"
97
97
 
98
98
  __all__ = [
99
99
  "__version__",
@@ -120,7 +120,7 @@ __all__ = [
120
120
  "get_pump_reverse",
121
121
  "FirmwareType", "FIRMWARE_TYPE_LABELS_ETM", "HardwareInfo",
122
122
  # firmware
123
- "firmware_label", "FIRMWARE_CLOUD_HOST", "ImageId", "FirmwareImage", "installable_images", "firmware_images",
123
+ "firmware_label", "main_firmware_version", "FIRMWARE_CLOUD_HOST", "ImageId", "FirmwareImage", "installable_images", "firmware_images",
124
124
  "firmware_check_url", "AvailableFirmware", "FirmwareCheck", "parse_firmware_check",
125
125
  "ChangelogEntry", "Changelog", "parse_changelog", "version_tuple",
126
126
  "Color", "COLOR_LABELS", "RadioType", "RADIO_TYPE_LABELS",
@@ -153,7 +153,7 @@ __all__ = [
153
153
  "format_mesh_address", "posix_tz_string", "TIME_ZONE_FIELD_SIZE",
154
154
  "RelayedMobiusDevice",
155
155
  # device
156
- "MobiusDevice", "MobiusPump", "LightIntensityResult", "LightPollResult", "FullPollResult",
156
+ "MobiusDevice", "LightIntensityResult", "LightPollResult", "FullPollResult",
157
157
  "schedule_capacity", "min_schedule_capacity", "tank_group",
158
158
  "SERVICE_GENERAL", "CHAR_RX_DATA", "CHAR_RX_FINAL", "CHAR_TX_DATA", "CHAR_TX_FINAL",
159
159
  # discovery
@@ -13,19 +13,22 @@ import argparse
13
13
  import asyncio
14
14
  import contextlib
15
15
  import json
16
+ import logging
16
17
  import struct
17
18
  from typing import Optional
18
19
 
19
20
  from .checksums import configured_scenes_checksum, schedule_checksum, supported_attributes_crc
20
- from .device import ALL_ELEMENTS, MobiusDevice, min_schedule_capacity
21
+ from .device import (
22
+ ALL_ELEMENTS, MobiusDevice, min_schedule_capacity, decode_mesh_peer_arrays, decode_networked_thread_devices,
23
+ )
21
24
  from .relay import RelayedMobiusDevice
22
25
  from .device_status import MeshPeer
23
26
  from .mesh_address import (
24
27
  is_valid_mesh_address, mesh_local_prefix_from_own_address,
25
- mesh_local_prefix_from_mlprefix, build_rloc_address, extract_short_address,
28
+ mesh_local_prefix_from_mlprefix,
26
29
  )
27
30
  from .constants import (
28
- PrimitiveType, C2Attribute, Model, enum_or_none,
31
+ PrimitiveType, C2Attribute,
29
32
  primitive_type_from_name, support_tier,
30
33
  )
31
34
  from .discovery import (
@@ -82,13 +85,11 @@ async def _dump_schedule(device: MobiusDevice, support: str) -> None:
82
85
  and for lights the lunar/insolation/intensity/acclimation state."""
83
86
  print(" --- full schedule ---")
84
87
  if support == "light":
85
- points = await device.get_light_schedule(which=1)
86
- for point in sorted(points, key=lambda p: p.time_minutes):
88
+ poll = await device.get_light_poll_batch(which=1)
89
+ for point in sorted(poll.schedule_points, key=lambda p: p.time_minutes):
87
90
  print(f" {_format_time(point.time_minutes)}{_format_flags(point)}: "
88
91
  f"{_format_channels(point.light.channels)}")
89
-
90
- lunar_enabled = await device.get_lunar_enabled()
91
- print(f" lunar_enabled: {lunar_enabled}")
92
+ print(f" lunar_enabled: {poll.lunar_enabled}")
92
93
 
93
94
  # Firmware-computed lunar state, to compare with the calculated
94
95
  # "current_intensities"/"light_diagnostics" of the summary.
@@ -98,12 +99,9 @@ async def _dump_schedule(device: MobiusDevice, support: str) -> None:
98
99
  except Exception as e:
99
100
  print(f" lunar_phase_info: read failed: {e}")
100
101
 
101
- insolation_active = await device.get_insolation_enabled()
102
- schedule_intensity = await device.get_schedule_intensity()
103
- acclimation = await device.get_acclimation_info()
104
- print(f" insolation_active: {insolation_active}")
105
- print(f" schedule_intensity: {schedule_intensity}")
106
- print(f" acclimation: {acclimation}")
102
+ print(f" insolation_active: {poll.intensities.diagnostics['insolation_active']}")
103
+ print(f" schedule_intensity: {poll.schedule_intensity}")
104
+ print(f" acclimation: {poll.acclimation}")
107
105
 
108
106
  elif support.startswith("pump"):
109
107
  points = await device.get_pump_schedule(which=1)
@@ -120,6 +118,18 @@ def _support_from_primitive_type(primitive_type_name: str | None) -> str:
120
118
  return support_tier(primitive_type_from_name(primitive_type_name))
121
119
 
122
120
 
121
+ async def _target_identity(target, serial: str, fallback_support: str) -> tuple[str, Optional[int]]:
122
+ """(support tier, group_mask) read from `target` itself; when its device
123
+ info can't be read, (`fallback_support`, None) with a message."""
124
+ try:
125
+ info = await target.get_device_info()
126
+ except Exception as e:
127
+ print(f" Could not read {serial!r}'s own device info ({e}) -- using support={fallback_support!r} "
128
+ f"and leaving out the rest of its schedule group.")
129
+ return fallback_support, None
130
+ return _support_from_primitive_type(info.get("primitive_type")), info.get("group_mask")
131
+
132
+
123
133
  async def _dump_schedule_to_file(device: MobiusDevice, support: str, serial: str, path: str) -> int:
124
134
  """
125
135
  Writes Schedule1 and Schedule2 to `path` as JSON
@@ -200,8 +210,7 @@ async def _dump_schedule_to_mob_file(device: MobiusDevice, support: str, path: s
200
210
  points = await device.get_light_schedule(which=1)
201
211
  mob = light_schedule_to_mob(points, template_name)
202
212
  elif support.startswith("pump"):
203
- info = await device.get_device_info()
204
- primitive_type = PrimitiveType[info["primitive_type"]]
213
+ primitive_type = await device.identify_device_type()
205
214
  points = await device.get_pump_schedule(which=1)
206
215
  mob = pump_schedule_to_mob(points, primitive_type, template_name)
207
216
  else:
@@ -235,7 +244,7 @@ async def _load_schedule_from_mob_file(device: MobiusDevice, support: str, path:
235
244
 
236
245
  async def _resolve_group_targets(
237
246
  gateway: MobiusDevice, target_serial: str, target_device, target_group_mask,
238
- support: str, debug_relay: bool, pan_id: Optional[int] = None,
247
+ support: str, pan_id: Optional[int] = None,
239
248
  stack: Optional[contextlib.AsyncExitStack] = None, args=None,
240
249
  ) -> list[tuple[str, object]]:
241
250
  """
@@ -267,7 +276,7 @@ async def _resolve_group_targets(
267
276
  for peer in (tank.peers if tank is not None else []):
268
277
  if peer.serial == target_serial:
269
278
  continue
270
- relayed = RelayedMobiusDevice(gateway, peer, debug=debug_relay)
279
+ relayed = RelayedMobiusDevice(gateway, peer)
271
280
  try:
272
281
  peer_info = await relayed.get_device_info()
273
282
  except Exception as e:
@@ -378,7 +387,7 @@ async def _check_checksums(target) -> None:
378
387
  if data_attr not in supported_ids:
379
388
  print(f" {label}: not supported by this device")
380
389
  continue
381
- values = await target.get_attribute_all(data_attr, 0, ALL_ELEMENTS)
390
+ values = await target.get_attribute(data_attr, 0, ALL_ELEMENTS)
382
391
  if data_attr == C2Attribute.ConfiguredScenes:
383
392
  computed = configured_scenes_checksum(values)
384
393
  used = sum(1 for v in values if len(v) >= 2 and struct.unpack_from("<H", v)[0] != 0)
@@ -410,7 +419,7 @@ async def _debug_mesh_discovery(device) -> list:
410
419
  print(f" MLPrefix OK. Derived mesh-local prefix: {prefix.hex()}")
411
420
  try:
412
421
  entries_raw = await device.get_attribute(
413
- C2Attribute.NetworkedThreadDevices, index=0, count=0xFFFF,
422
+ C2Attribute.NetworkedThreadDevices, index=0, count=ALL_ELEMENTS,
414
423
  )
415
424
  except Exception as e:
416
425
  print(f" NetworkedThreadDevices FAILED: {e!r}")
@@ -418,25 +427,10 @@ async def _debug_mesh_discovery(device) -> list:
418
427
  print(f" NetworkedThreadDevices returned {len(entries_raw)} element(s): "
419
428
  f"{[e.hex() for e in entries_raw]}")
420
429
 
421
- peers = []
422
430
  for entry in entries_raw:
423
431
  if len(entry) != 28:
424
- print(f" Skipping malformed entry (expected 28 bytes, got "
425
- f"{len(entry)}): {entry.hex()}")
426
- continue
427
- try:
428
- serial = entry[14:28].decode("ascii").rstrip("\x00")
429
- except UnicodeDecodeError:
430
- continue
431
- if not serial or serial == "00000000000000":
432
- continue
433
- address = prefix + entry[0:8]
434
- model_raw = struct.unpack("<h", entry[12:14])[0]
435
- peers.append(MeshPeer(
436
- serial=serial, model_raw=model_raw, model=enum_or_none(Model, model_raw),
437
- short_address=extract_short_address(address), address=address,
438
- age=struct.unpack("<I", entry[8:12])[0],
439
- ))
432
+ print(f" Skipping malformed entry (expected 28 bytes, got {len(entry)}): {entry.hex()}")
433
+ peers = decode_networked_thread_devices(prefix, entries_raw)
440
434
  if peers:
441
435
  print(f" Parsed {len(peers)} peer(s) via NetworkedThreadDevices -- "
442
436
  f"skipping the MXM-hub steps below.")
@@ -474,7 +468,7 @@ async def _debug_mesh_discovery(device) -> list:
474
468
  for attr in (C2Attribute.SerialNumberArray, C2Attribute.DeviceModelArray, C2Attribute.ShortAddressArray):
475
469
  print(f" Step 2: {attr.name} ({attr.value})...")
476
470
  try:
477
- values = await device.get_attribute(attr, index=0, count=0xFFFF)
471
+ values = await device.get_attribute(attr, index=0, count=ALL_ELEMENTS)
478
472
  except Exception as e:
479
473
  print(f" FAILED: {e!r}")
480
474
  values = []
@@ -482,24 +476,10 @@ async def _debug_mesh_discovery(device) -> list:
482
476
  print(f" Returned {len(values)} element(s): {[v.hex() for v in values]}")
483
477
  arrays[attr] = values
484
478
 
485
- peers = []
486
- for serial_bytes, model_bytes, short_bytes in zip(
487
- arrays[C2Attribute.SerialNumberArray], arrays[C2Attribute.DeviceModelArray],
479
+ return decode_mesh_peer_arrays(
480
+ prefix, arrays[C2Attribute.SerialNumberArray], arrays[C2Attribute.DeviceModelArray],
488
481
  arrays[C2Attribute.ShortAddressArray],
489
- ):
490
- try:
491
- serial = serial_bytes.decode("ascii").rstrip("\x00")
492
- except (UnicodeDecodeError, AttributeError):
493
- continue
494
- if not serial or serial == "00000000000000" or len(model_bytes) < 2 or len(short_bytes) < 2:
495
- continue
496
- model_raw = struct.unpack("<h", model_bytes[:2])[0]
497
- short_address = struct.unpack("<H", short_bytes[:2])[0]
498
- peers.append(MeshPeer(
499
- serial=serial, model_raw=model_raw, model=enum_or_none(Model, model_raw),
500
- short_address=short_address, address=build_rloc_address(prefix, short_address),
501
- ))
502
- return peers
482
+ )
503
483
 
504
484
 
505
485
  def _print_mesh_peers_or_hint(peers: list) -> None:
@@ -511,6 +491,28 @@ def _print_mesh_peers_or_hint(peers: list) -> None:
511
491
  _print_peers(peers)
512
492
 
513
493
 
494
+ # Diagnostic getters printed by --dump-diagnostics, in order.
495
+ _DIAGNOSTIC_GETTERS = (
496
+ "get_fsci_diagnostic_info", "get_clock_sync_info", "get_thread_provisioning_info",
497
+ "get_thread_network_state_info", "get_ble_connection_info", "get_scene_diagnostic_info",
498
+ "get_calibration_extra_info", "get_legacy_device_info", "get_wifi_remnant_info",
499
+ "get_engineering_diagnostic_info", "get_channel_intensities", "get_insolation_tables",
500
+ "get_device_local_date",
501
+ )
502
+
503
+
504
+ async def _dump_diagnostics(device: MobiusDevice) -> None:
505
+ """Prints every diagnostic getter's result (each field is None when the
506
+ device doesn't support it)."""
507
+ print(" --- diagnostics ---")
508
+ for name in _DIAGNOSTIC_GETTERS:
509
+ try:
510
+ value = await getattr(device, name)()
511
+ except Exception as e:
512
+ value = f"read failed: {e}"
513
+ print(f" {name.removeprefix('get_')}: {value}")
514
+
515
+
514
516
  async def _print_summary(mdevice: MobiusDevice, args) -> dict:
515
517
  """Prints get_device_summary() plus the optional schedule/scene dumps."""
516
518
  summary = await mdevice.get_device_summary()
@@ -520,6 +522,8 @@ async def _print_summary(mdevice: MobiusDevice, args) -> dict:
520
522
  await _dump_schedule(mdevice, summary.get("support", ""))
521
523
  if args.dump_scenes:
522
524
  await _dump_scenes(mdevice, primitive_type_from_name(summary.get("primitive_type")))
525
+ if args.dump_diagnostics:
526
+ await _dump_diagnostics(mdevice)
523
527
  return summary
524
528
 
525
529
 
@@ -538,11 +542,8 @@ async def _learn_peer_directly(args, found_target) -> Optional[MeshPeer]:
538
542
  return None
539
543
  print(f" Learned address directly: {target_address.hex()}")
540
544
  return MeshPeer(
541
- serial=args.relay_target,
542
- model_raw=target_info.model_raw if target_info else 0,
543
- model=target_info.model if target_info else None,
544
- short_address=extract_short_address(target_address),
545
- address=target_address,
545
+ serial=args.relay_target, address=target_address,
546
+ model_raw=target_info.model_raw if target_info else None,
546
547
  )
547
548
 
548
549
 
@@ -556,20 +557,14 @@ async def _write_to_group(mdevice, args, target, label: str, serial: str, summar
556
557
  exceeds the smallest capacity in the group. `write(device, support,
557
558
  serial)` performs the write for one device and returns a summary line.
558
559
  """
559
- try:
560
- target_info = await target.get_device_info()
561
- target_group_mask = target_info.get("group_mask")
562
- # Read from the actual target: the summary describes the gateway.
563
- target_support = _support_from_primitive_type(target_info.get("primitive_type"))
564
- except Exception as e:
565
- print(f" Could not read {serial!r}'s own device info ({e}) -- "
566
- f"writing only to it, not the rest of its group.")
567
- target_group_mask = None
568
- target_support = summary.get("support", "") if not args.relay_target else ""
560
+ # Read from the actual target: the summary describes the gateway.
561
+ target_support, target_group_mask = await _target_identity(
562
+ target, serial, summary.get("support", "") if not args.relay_target else "",
563
+ )
569
564
 
570
565
  print(f"\n=== resolving schedule-group targets for {label} ===")
571
566
  group_targets = await _resolve_group_targets(
572
- mdevice, serial, target, target_group_mask, target_support, args.debug_relay,
567
+ mdevice, serial, target, target_group_mask, target_support,
573
568
  pan_id=pan_id, stack=stack, args=args,
574
569
  )
575
570
  if len(group_targets) > 1:
@@ -653,7 +648,7 @@ async def _run_by_serial(args) -> None:
653
648
  f"brief direct connection to {args.relay_target!r} to learn its address...")
654
649
  target_peer = await _learn_peer_directly(args, found_target)
655
650
  if target_peer is not None:
656
- relayed = RelayedMobiusDevice(mdevice, target_peer, debug=args.debug_relay)
651
+ relayed = RelayedMobiusDevice(mdevice, target_peer)
657
652
  try:
658
653
  for k, v in (await relayed.get_device_summary()).items():
659
654
  print(f" {k}: {v}")
@@ -678,12 +673,10 @@ async def _run_by_serial(args) -> None:
678
673
  return f"{args.relay_target!r} (via relay through {args.by_serial!r})", relayed, args.relay_target
679
674
 
680
675
  async def target_support(target, serial: str) -> str:
681
- try:
682
- return _support_from_primitive_type((await target.get_device_info()).get("primitive_type"))
683
- except Exception as e:
684
- print(f" Could not read {serial!r}'s own device info ({e}) -- "
685
- f"falling back to {args.by_serial!r}'s own support.")
686
- return summary.get("support", "") if not args.relay_target else ""
676
+ support, _group_mask = await _target_identity(
677
+ target, serial, summary.get("support", "") if not args.relay_target else "",
678
+ )
679
+ return support
687
680
 
688
681
  if args.reboot and (picked := pick_target("reboot()")):
689
682
  label, target, _serial = picked
@@ -971,6 +964,11 @@ def main() -> None:
971
964
  help="Also print every configured scene on each connected device -- "
972
965
  "id, name, timeout and light/pump payload of every slot, including "
973
966
  "empty ones.")
967
+ parser.add_argument("--dump-diagnostics", action="store_true",
968
+ help="Also print each connected device's diagnostic attributes: FSCI, "
969
+ "clock sync, Thread provisioning and network state, BLE connection, "
970
+ "scene, calibration, legacy, Wi-Fi and engineering diagnostics, "
971
+ "channel intensities, insolation tables and the device's local date.")
974
972
  parser.add_argument("--dump-mesh-peers", action="store_true",
975
973
  help="Only usable with --by-serial. After connecting, calls "
976
974
  "discover_mesh_peers_auto() on it and prints every other device "
@@ -988,11 +986,10 @@ def main() -> None:
988
986
  "get_device_summary() read to it through the gateway and prints "
989
987
  "the result. The other --by-serial actions apply to this target.")
990
988
  parser.add_argument("--debug-relay", action="store_true",
991
- help="Only usable with --relay-target. Prints raw bytes at every "
992
- "layer of each relayed request/response (outer frame, CoAP "
993
- "envelope fields, inner frame), plus every frame the gateway "
994
- "receives while waiting (the opcode-25 ack and the opcode-26 "
995
- "indication).")
989
+ help="Logs the raw bytes of every layer of each relayed "
990
+ "request/response (inner frame, outer frame, CoAP response) and "
991
+ "every frame the gateway receives (debug logging for "
992
+ "mobius.relay and mobius.device).")
996
993
  parser.add_argument("--set-time-to-now", action="store_true",
997
994
  help="Only usable with --by-serial. A WRITE -- use on hardware you're "
998
995
  "prepared to have its clock changed on. Calls set_time_to_now() "
@@ -1106,6 +1103,11 @@ def main() -> None:
1106
1103
  print(f"\t{element['raw_hex']}{suffix}")
1107
1104
  return
1108
1105
 
1106
+ if args.debug_relay:
1107
+ logging.basicConfig(format=" [%(name)s] %(message)s")
1108
+ for name in ("mobius.relay", "mobius.device"):
1109
+ logging.getLogger(name).setLevel(logging.DEBUG)
1110
+
1109
1111
  asyncio.run(_run(args))
1110
1112
 
1111
1113