python-mobius 0.7.1__tar.gz → 0.8.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 (97) hide show
  1. {python_mobius-0.7.1 → python_mobius-0.8.0}/CHANGELOG.md +38 -0
  2. {python_mobius-0.7.1 → python_mobius-0.8.0}/PKG-INFO +27 -4
  3. {python_mobius-0.7.1 → python_mobius-0.8.0}/README.md +26 -3
  4. {python_mobius-0.7.1 → python_mobius-0.8.0}/documentation/00-overview.md +2 -2
  5. {python_mobius-0.7.1 → python_mobius-0.8.0}/documentation/03-attributes-and-opcodes.md +2 -1
  6. python_mobius-0.8.0/documentation/05-scenes.md +137 -0
  7. {python_mobius-0.7.1 → python_mobius-0.8.0}/documentation/06-light-schedule.md +92 -0
  8. python_mobius-0.8.0/documentation/07-pump-schedule.md +185 -0
  9. {python_mobius-0.7.1 → python_mobius-0.8.0}/documentation/09-thread-coap-relay.md +15 -0
  10. {python_mobius-0.7.1 → python_mobius-0.8.0}/documentation/10-known-gaps-and-open-questions.md +40 -21
  11. {python_mobius-0.7.1 → python_mobius-0.8.0}/documentation/11-device-status-attributes.md +14 -0
  12. {python_mobius-0.7.1 → python_mobius-0.8.0}/documentation/13-attribute-dump.md +1 -1
  13. python_mobius-0.8.0/documentation/14-batched-attribute-reads.md +112 -0
  14. python_mobius-0.8.0/documentation/15-mob-template-format.md +107 -0
  15. {python_mobius-0.7.1 → python_mobius-0.8.0}/pyproject.toml +1 -1
  16. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/__init__.py +22 -5
  17. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/cli.py +541 -16
  18. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/coap.py +35 -36
  19. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/constants.py +142 -81
  20. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/crc.py +3 -3
  21. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/device.py +873 -601
  22. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/device_status.py +146 -146
  23. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/discovery.py +12 -13
  24. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/dump.py +35 -36
  25. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/frame.py +14 -14
  26. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/manufacturer.py +8 -8
  27. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/mesh_address.py +11 -11
  28. python_mobius-0.8.0/src/mobius/mob.py +188 -0
  29. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/modifiers.py +5 -16
  30. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/power.py +7 -7
  31. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/pump_status.py +6 -6
  32. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/relay.py +67 -22
  33. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/schedule.py +164 -16
  34. python_mobius-0.8.0/tests/test_cli_group_targets.py +296 -0
  35. python_mobius-0.8.0/tests/test_cli_mob_file.py +192 -0
  36. python_mobius-0.8.0/tests/test_cli_schedule_file.py +274 -0
  37. python_mobius-0.8.0/tests/test_device_control.py +165 -0
  38. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_device_summary.py +84 -0
  39. python_mobius-0.8.0/tests/test_extra_physical_values.py +79 -0
  40. python_mobius-0.8.0/tests/test_fan_thresholds.py +93 -0
  41. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_full_poll_batch.py +71 -1
  42. python_mobius-0.8.0/tests/test_group_bitmap.py +145 -0
  43. python_mobius-0.8.0/tests/test_light_schedule.py +482 -0
  44. python_mobius-0.8.0/tests/test_mob.py +283 -0
  45. python_mobius-0.8.0/tests/test_pump_schedule.py +472 -0
  46. python_mobius-0.8.0/tests/test_pump_supported_modes.py +78 -0
  47. python_mobius-0.8.0/tests/test_pump_telemetry.py +264 -0
  48. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_relay.py +83 -0
  49. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_scenes.py +38 -0
  50. python_mobius-0.8.0/tests/test_schedule_file_format.py +165 -0
  51. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_schedule_intensity_scalar.py +82 -0
  52. python_mobius-0.7.1/documentation/05-scenes.md +0 -55
  53. python_mobius-0.7.1/documentation/07-pump-schedule.md +0 -120
  54. python_mobius-0.7.1/documentation/14-batched-attribute-reads.md +0 -80
  55. python_mobius-0.7.1/tests/test_device_control.py +0 -59
  56. python_mobius-0.7.1/tests/test_light_schedule.py +0 -142
  57. python_mobius-0.7.1/tests/test_pump_schedule.py +0 -148
  58. python_mobius-0.7.1/tests/test_pump_telemetry.py +0 -133
  59. {python_mobius-0.7.1 → python_mobius-0.8.0}/.forgejo/scripts/extract_changelog_section.py +0 -0
  60. {python_mobius-0.7.1 → python_mobius-0.8.0}/.forgejo/workflows/release.yml +0 -0
  61. {python_mobius-0.7.1 → python_mobius-0.8.0}/.gitignore +0 -0
  62. {python_mobius-0.7.1 → python_mobius-0.8.0}/LICENSE +0 -0
  63. {python_mobius-0.7.1 → python_mobius-0.8.0}/documentation/01-ble-transport.md +0 -0
  64. {python_mobius-0.7.1 → python_mobius-0.8.0}/documentation/02-framing-and-crc.md +0 -0
  65. {python_mobius-0.7.1 → python_mobius-0.8.0}/documentation/04-device-identity.md +0 -0
  66. {python_mobius-0.7.1 → python_mobius-0.8.0}/documentation/08-manufacturer-data.md +0 -0
  67. {python_mobius-0.7.1 → python_mobius-0.8.0}/documentation/12-device-identity-and-address-stability.md +0 -0
  68. {python_mobius-0.7.1 → python_mobius-0.8.0}/src/mobius/scenes.py +0 -0
  69. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_attributes_batch.py +0 -0
  70. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_coap.py +0 -0
  71. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_coap_indication_handling.py +0 -0
  72. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_connection_cleanup.py +0 -0
  73. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_device_status.py +0 -0
  74. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_device_time.py +0 -0
  75. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_discovery_company_ids.py +0 -0
  76. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_dump.py +0 -0
  77. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_firmware_versions.py +0 -0
  78. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_frame.py +0 -0
  79. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_frame_sniffer.py +0 -0
  80. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_hyperdrive_device.py +0 -0
  81. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_insolation_detection.py +0 -0
  82. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_light_intensity_branching.py +0 -0
  83. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_light_poll_batch.py +0 -0
  84. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_manufacturer.py +0 -0
  85. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_manufacturer_lookup.py +0 -0
  86. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_mesh_address.py +0 -0
  87. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_mesh_discovery.py +0 -0
  88. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_mesh_discovery_direct_connect.py +0 -0
  89. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_metadata_batch.py +0 -0
  90. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_modifiers.py +0 -0
  91. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_networked_thread_devices.py +0 -0
  92. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_new_diagnostic_attributes.py +0 -0
  93. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_power.py +0 -0
  94. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_pump_status.py +0 -0
  95. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_serial_decoding.py +0 -0
  96. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_serial_identity.py +0 -0
  97. {python_mobius-0.7.1 → python_mobius-0.8.0}/tests/test_supported_attributes.py +0 -0
@@ -1,5 +1,43 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.8.0
4
+
5
+ - Added `supported_pump_modes(primitive_type, closed_loop=None)`: the
6
+ `PumpMode` values a given pump actually supports. See
7
+ documentation/07-pump-schedule.md's own "Which modes a given pump
8
+ actually supports" section.
9
+ - Added schedule write support: set_light_schedule()/set_pump_schedule(),
10
+ GroupBitmap (group_mask), and --dump-schedule-file/--load-schedule-file
11
+ CLI flags.
12
+ - Added support for .mob (Template) files -- export/import for both
13
+ light and pump schedules, plus --dump-mob-file/--load-mob-file CLI
14
+ flags.
15
+ - --load-schedule-file/--load-mob-file now write to every device in a
16
+ light's schedule group, not just the one named, and check the whole
17
+ group's smallest schedule capacity up front so a too-large schedule
18
+ aborts cleanly instead of partially applying.
19
+ - get_pump_telemetry() now also includes every other sensor value a
20
+ pump reports supporting (motor RPM, temperatures, battery/input
21
+ voltage, etc.) under a new extra_physical_values key, alongside the
22
+ existing gph/motor_power_watts.
23
+ - Added get_extra_physical_values() -- the same extra sensor values as
24
+ above, for callers with no get_pump_telemetry()-equivalent method of
25
+ their own. get_device_summary() uses this for lights specifically
26
+ (ClusterTemperature/DriverTemperature/etc.), which previously had no
27
+ way to surface this data at all.
28
+ - Added get_fan_thresholds() -- FanOnTemperature/FanOffTemperature,
29
+ shown unconditionally in get_device_summary().
30
+
31
+ ## 0.7.2
32
+
33
+ - get_full_poll_batch() now includes scene data (ConfiguredScenes/
34
+ CurrentScene) in the same combined request, falling back to
35
+ get_configured_scenes()/get_current_scene() if the batch itself
36
+ fails.
37
+ - start_scene()/start_feed_mode() now broadcast to the whole mesh by
38
+ default (one write instead of one per device) -- pass
39
+ broadcast=False for the old, single-device-only behavior.
40
+
3
41
  ## 0.7.1
4
42
 
5
43
  - Added scene support: get_configured_scenes(), get_current_scene(),
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-mobius
3
- Version: 0.7.1
3
+ Version: 0.8.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
@@ -102,20 +102,43 @@ mobius-scan --adapter hci0
102
102
  - **Discover devices** and group them by tank/mesh (`pan_id`), reading
103
103
  model/serial straight from BLE advertisements — no connection required.
104
104
  - **Read pump telemetry**: current speed, estimated flow (GPH), operation
105
- state, error state, and (Vectra/NYOS Quantum pumps) motor power in watts.
105
+ state, error state, and (Vectra/NYOS Quantum pumps) motor power in watts
106
+ plus every OTHER live sensor value (motor RPM, temperatures, battery/input
107
+ voltage, etc.) a specific pump reports supporting, merged into the same
108
+ result under `extra_physical_values` rather than a separate call. Lights
109
+ get the same extra sensor values too (`get_extra_physical_values()`) —
110
+ a light has no telemetry method of its own to merge them into, but
111
+ `get_device_summary()` still surfaces them the same way.
106
112
  - **Read pump schedules**: which mode (constant speed, tidal swell, pulse,
107
113
  etc.) is active at any given time, exactly as programmed.
108
114
  - **Read light schedules**: per-channel intensity at any given time,
109
115
  replicating the app's own client-side interpolation (there's no "current
110
116
  intensity" attribute — lights only expose the programmed curve).
117
+ - **Read every configured scene** (`get_configured_scenes()`) — name,
118
+ timeout, and its own light/pump payload, at whatever slot it lives in,
119
+ plus the currently active one (`get_current_scene()`).
120
+ - **Control scenes**: start feed mode, resume the normal schedule, or any
121
+ other configured scene — one write, broadcast to the whole mesh by
122
+ default (`broadcast=True`). Uses the same mesh-propagation mechanism
123
+ confirmed against real hardware for the clock-sync write below;
124
+ applying it to scene activation specifically hasn't been
125
+ independently verified the same way yet.
126
+ - **Fetch everything one device poll needs in a single round-trip**
127
+ (`get_full_poll_batch()`) — identity, metadata, light/pump state, and
128
+ scene data together, confirmed 2-2.6x faster on real hardware than
129
+ reading each piece separately.
111
130
  - **Read device-specific settings**: VorTech's own "Local Control"/"Led
112
131
  Auto Dim" and Radion's own "Max Fan Speed"/"Fan Shutdown"
113
132
  (`get_advanced_features()`), plus Vectra and NYOS Quantum settings
114
133
  (`get_vectra_info()`/`get_coffee_info()` — no real hardware to verify
115
134
  either against, see
116
135
  [known gaps](./documentation/10-known-gaps-and-open-questions.md)).
117
- - **Control scenes**: start feed mode, resume the normal schedule, or any
118
- other configured scene.
136
+ - **Read a device's own fan protection thresholds** (`get_fan_thresholds()`)
137
+ FanOnTemperature/FanOffTemperature, if supported at all; confirmed
138
+ against real hardware that neither a Radion light nor a VorTech pump
139
+ reports supporting either one, and — forcing a direct read anyway —
140
+ that the device actively rejects it (`FsciStatus.UnsupportedAttribute`),
141
+ not merely omitting it while still functioning underneath.
119
142
  - **Fix a desynced device clock** (`set_time_to_now()`) — a WRITE.
120
143
  Writing to one device appears to propagate to the rest of its Thread
121
144
  mesh too, confirmed against real hardware — see
@@ -70,20 +70,43 @@ mobius-scan --adapter hci0
70
70
  - **Discover devices** and group them by tank/mesh (`pan_id`), reading
71
71
  model/serial straight from BLE advertisements — no connection required.
72
72
  - **Read pump telemetry**: current speed, estimated flow (GPH), operation
73
- state, error state, and (Vectra/NYOS Quantum pumps) motor power in watts.
73
+ state, error state, and (Vectra/NYOS Quantum pumps) motor power in watts
74
+ plus every OTHER live sensor value (motor RPM, temperatures, battery/input
75
+ voltage, etc.) a specific pump reports supporting, merged into the same
76
+ result under `extra_physical_values` rather than a separate call. Lights
77
+ get the same extra sensor values too (`get_extra_physical_values()`) —
78
+ a light has no telemetry method of its own to merge them into, but
79
+ `get_device_summary()` still surfaces them the same way.
74
80
  - **Read pump schedules**: which mode (constant speed, tidal swell, pulse,
75
81
  etc.) is active at any given time, exactly as programmed.
76
82
  - **Read light schedules**: per-channel intensity at any given time,
77
83
  replicating the app's own client-side interpolation (there's no "current
78
84
  intensity" attribute — lights only expose the programmed curve).
85
+ - **Read every configured scene** (`get_configured_scenes()`) — name,
86
+ timeout, and its own light/pump payload, at whatever slot it lives in,
87
+ plus the currently active one (`get_current_scene()`).
88
+ - **Control scenes**: start feed mode, resume the normal schedule, or any
89
+ other configured scene — one write, broadcast to the whole mesh by
90
+ default (`broadcast=True`). Uses the same mesh-propagation mechanism
91
+ confirmed against real hardware for the clock-sync write below;
92
+ applying it to scene activation specifically hasn't been
93
+ independently verified the same way yet.
94
+ - **Fetch everything one device poll needs in a single round-trip**
95
+ (`get_full_poll_batch()`) — identity, metadata, light/pump state, and
96
+ scene data together, confirmed 2-2.6x faster on real hardware than
97
+ reading each piece separately.
79
98
  - **Read device-specific settings**: VorTech's own "Local Control"/"Led
80
99
  Auto Dim" and Radion's own "Max Fan Speed"/"Fan Shutdown"
81
100
  (`get_advanced_features()`), plus Vectra and NYOS Quantum settings
82
101
  (`get_vectra_info()`/`get_coffee_info()` — no real hardware to verify
83
102
  either against, see
84
103
  [known gaps](./documentation/10-known-gaps-and-open-questions.md)).
85
- - **Control scenes**: start feed mode, resume the normal schedule, or any
86
- other configured scene.
104
+ - **Read a device's own fan protection thresholds** (`get_fan_thresholds()`)
105
+ FanOnTemperature/FanOffTemperature, if supported at all; confirmed
106
+ against real hardware that neither a Radion light nor a VorTech pump
107
+ reports supporting either one, and — forcing a direct read anyway —
108
+ that the device actively rejects it (`FsciStatus.UnsupportedAttribute`),
109
+ not merely omitting it while still functioning underneath.
87
110
  - **Fix a desynced device clock** (`set_time_to_now()`) — a WRITE.
88
111
  Writing to one device appears to propagate to the rest of its Thread
89
112
  mesh too, confirmed against real hardware — see
@@ -45,7 +45,7 @@ Interface conventions, extended by EcoTech).
45
45
  | [02-framing-and-crc.md](./02-framing-and-crc.md) | Wire frame layout, CRC16 algorithm |
46
46
  | [03-attributes-and-opcodes.md](./03-attributes-and-opcodes.md) | Get/Set-attribute opcodes, payload format, the `C2Attribute` table |
47
47
  | [04-device-identity.md](./04-device-identity.md) | `PrimitiveType`, `Model`, `ErrorState`, support tiers |
48
- | [05-scenes.md](./05-scenes.md) | Scene control (feed mode, resume schedule, etc.) |
48
+ | [05-scenes.md](./05-scenes.md) | Scene control (reading configured scenes, activating one, mesh-wide broadcast, resuming the schedule) |
49
49
  | [06-light-schedule.md](./06-light-schedule.md) | Light point-schedule format + client-side interpolation |
50
50
  | [07-pump-schedule.md](./07-pump-schedule.md) | Pump primitive format, mode/parameter tables, block lookup |
51
51
  | [08-manufacturer-data.md](./08-manufacturer-data.md) | BLE advertisement format (no-connection discovery) |
@@ -54,7 +54,7 @@ Interface conventions, extended by EcoTech).
54
54
  | [11-device-status-attributes.md](./11-device-status-attributes.md) | Flow range, override mode, battery backup, group/sync, calibration (light-only), maintenance |
55
55
  | [12-device-identity-and-address-stability.md](./12-device-identity-and-address-stability.md) | Why device identity is serial-number-based, not address-based |
56
56
  | [13-attribute-dump.md](./13-attribute-dump.md) | Reading every attribute a device supports, enriched JSON output, the app-compatible text format |
57
- | [14-batched-attribute-reads.md](./14-batched-attribute-reads.md) | Reading multiple, different attributes in one round-trip |
57
+ | [14-batched-attribute-reads.md](./14-batched-attribute-reads.md) | Reading multiple, different attributes in one round-trip, and the combined-poll methods built on top |
58
58
 
59
59
  ## On the use of AI assistance
60
60
 
@@ -85,7 +85,8 @@ against real pumps and lights returned correctly-structured, sane data.
85
85
  | `MACAddress` | 103 | Observed unpopulated (empty response) on all devices tested |
86
86
  | `OperationState` | 104 | `OOB`/`LiveDemo`/`Scene`/`Schedule` |
87
87
  | `ErrorState` | 107 | See [04-device-identity.md](./04-device-identity.md) |
88
- | `CurrentScene` | 401 | Set to `sceneId(u16 LE) + durationMinutes(u16 LE)` to start a scene |
88
+ | `CurrentScene` | 401 | Set to `sceneId(u16 LE) + durationSeconds(u16 LE)` to start a scene -- see [05-scenes.md](./05-scenes.md) |
89
+ | `ConfiguredScenes` | 400 | Every scene this device has programmed, one per slot -- see [05-scenes.md](./05-scenes.md) |
89
90
  | `Schedule1` / `Schedule2` | 500 / 503 | The programmed point-schedule — see 06/07 |
90
91
  | `MotorSpeed` | 700 | Live pump speed (int16). Confirmed NOT RPM -- percentage of max power in tenths of a percent (abs(value)/10 = %); sign encodes rotation direction. Confirmed via the app's own display formatting. |
91
92
  | `LocalControlEnabled` / `AutoDimTimeout` / `MaxFanSpeed` / `FanShutdownEnabled` | 300 / 301 / 910 / 1203 | VorTech/Radion advanced feature settings — see 11 |
@@ -0,0 +1,137 @@
1
+ # Scenes
2
+
3
+ Scenes are device-side presets (feed mode, all-off, disco, a user-created
4
+ custom one, etc.) that can be activated on demand, separate from the
5
+ normal time-based schedule. A scene is genuinely tank-wide, not a
6
+ per-device concept — creating one (not implemented by this library; see
7
+ below) writes a matching entry, with the same `id`/`name`/`timeout`, to
8
+ every device in the tank that supports scenes and has a free slot.
9
+
10
+ ## `SceneID`
11
+
12
+ Confirmed literal values — the ten built-in presets. A user-created
13
+ custom scene's own `id` is a device-assigned value outside this range
14
+ (see "Configured scenes" below), not one of these:
15
+
16
+ | Value | Name |
17
+ |---|---|
18
+ | 0 | `EmptyScene` |
19
+ | 1 | `FeedMode` |
20
+ | 2 | `BatteryBackup` |
21
+ | 3 | `AllOff` |
22
+ | 4 | `ColorCycle` |
23
+ | 5 | `Disco` |
24
+ | 6 | `Thunderstorm` |
25
+ | 7 | `CloudCover` |
26
+ | 8 | `AllOn` |
27
+ | 9 | `All50` |
28
+
29
+ ## Configured scenes
30
+
31
+ `C2Attribute.ConfiguredScenes` (400) holds every scene this device has
32
+ programmed, one per slot, read via the same "get all elements" pattern
33
+ used for the point schedule (`index=0, count=0xFFFF`) — the number of
34
+ slots a device actually has is device-reported, not a fixed constant,
35
+ and can genuinely differ by device type. Each element is:
36
+
37
+ ```
38
+ id (u16 LE) + timeout (u16 LE) + name (16 bytes, null-padded) + primitiveData
39
+ ```
40
+
41
+ `primitiveData` is exactly the same payload a schedule point's own
42
+ primitive would be — a light's own per-channel intensities, or a pump's
43
+ own mode + parameters (see
44
+ [06-light-schedule.md](./06-light-schedule.md)/
45
+ [07-pump-schedule.md](./07-pump-schedule.md)) — just without the
46
+ schedule-point header (time/flags) wrapping it, since a scene isn't
47
+ tied to a time of day. A custom scene's own `id` is a randomly-chosen
48
+ value confirmed unique across the whole tank (not just this one
49
+ device); a genuinely empty/unused slot has `id=EmptyScene` and an empty
50
+ `name`.
51
+
52
+ `get_configured_scenes()` reads and decodes this into a `Scene` per
53
+ slot (`index`/`id`/`scene_type`/`name`/`timeout`/`light`/`pump`) —
54
+ `scene_type` is `None` whenever `id` doesn't match one of the ten
55
+ built-in presets, i.e. exactly the custom-scene case.
56
+
57
+ ## The currently active scene
58
+
59
+ `C2Attribute.CurrentScene` (401) reports whichever scene is currently
60
+ active:
61
+
62
+ ```
63
+ id (u16 LE) + durationSeconds (u16 LE)
64
+ ```
65
+
66
+ `get_current_scene()` reads and decodes this into an `ActiveScene`
67
+ (`id`/`scene_type`/`duration_seconds`) — `scene_type` is `None` for a
68
+ custom scene's own id here too, the same as above.
69
+
70
+ ## Starting a scene
71
+
72
+ `start_scene(scene_id, duration_seconds=0)` sets `CurrentScene` to:
73
+
74
+ ```
75
+ sceneId (u16 LE) + durationSeconds (u16 LE)
76
+ ```
77
+
78
+ `duration_seconds=0` lets the device use the scene's own configured
79
+ timeout rather than overriding it. The unit is confirmed to be
80
+ **seconds**, not minutes — traced directly through the app's own
81
+ duration-remaining calculation, which multiplies this same value by
82
+ 1000 to build a millisecond timestamp offset.
83
+
84
+ **Confirmed live and via byte-for-byte packet reconstruction**: building a
85
+ "start feed mode" frame with this library's own encoder reproduces a real
86
+ packet captured from the community reverse-engineering thread exactly —
87
+ see `tests/test_frame.py::test_build_frame_reproduces_captured_bytes_exactly`.
88
+
89
+ ### Broadcasting to the whole mesh
90
+
91
+ `start_scene()`/`start_feed_mode()` default to `broadcast=True`, which
92
+ sets the frame's own reserved-byte `group` field to 1 (see
93
+ [02-framing-and-crc.md](./02-framing-and-crc.md)) — a single write to
94
+ one device then propagates to the rest of its Thread mesh, the exact
95
+ same mechanism confirmed against real hardware for `set_time_to_now()`'s
96
+ own `Epoch` write (see
97
+ [09-thread-coap-relay.md](./09-thread-coap-relay.md)). Applying that
98
+ same mechanism to `CurrentScene` specifically hasn't been independently
99
+ verified against real hardware the same way — it's the app's own code
100
+ path for scene activation, not yet its own separate confirmation.
101
+ `group=1` only ever reaches devices on that same mesh; a Thread network
102
+ is its own isolated network (its own `pan_id`), so this cannot cross
103
+ into a different tank's own, separate mesh even within physical radio
104
+ range. Pass `broadcast=False` to write to only the one device instead.
105
+
106
+ ## Returning to the normal schedule
107
+
108
+ `resume_schedule()` sets `C2Attribute.OperationState` (104) to
109
+ `OperationState.Schedule` (3). This is what "cancel the current scene"
110
+ actually means at the protocol level — there's no separate "cancel"
111
+ opcode, just setting operation state back to schedule-driven. Unlike
112
+ scene activation, this write has no confirmed mesh-propagation evidence
113
+ of its own — call it per-device if you need it to apply tank-wide.
114
+
115
+ ## `OperationState`
116
+
117
+ Confirmed literal values:
118
+
119
+ | Value | Name |
120
+ |---|---|
121
+ | 0 | `OOB` (out-of-box, unconfigured) |
122
+ | 1 | `LiveDemo` |
123
+ | 2 | `Scene` |
124
+ | 3 | `Schedule` |
125
+
126
+ ## Not implemented: creating/editing a scene
127
+
128
+ Writing a new or modified `Scene` entry (rather than just activating an
129
+ existing one) is not implemented by this library. The wire format above
130
+ is fully confirmed and sufficient to build one, but doing so correctly
131
+ means writing it to every scene-capable device in the tank individually
132
+ (no broadcast shortcut exists for this — `ConfiguredScenes` is written
133
+ per-device, per-slot, not as a single group write), choosing a slot that
134
+ isn't already in use on each one, and — for a genuinely custom scene —
135
+ generating an `id` confirmed unique across the whole tank first. See
136
+ [10-known-gaps-and-open-questions.md](./10-known-gaps-and-open-questions.md).
137
+
@@ -29,6 +29,11 @@ regular per-channel value some models expose, not this).
29
29
  0-1000, default 500/50% per the app's own initialization code).
30
30
  `get_current_light_intensities()` applies it automatically.
31
31
 
32
+ `set_schedule_intensity()` writes it back -- a plain `Set` mirroring the
33
+ same encoding, taking a 0.0-1.0 fraction (matching the getter's own
34
+ return range) and raising `ValueError` before writing anything if it's
35
+ out of that range.
36
+
32
37
  Lunar-phase and acclimation-ramp reductions layer on top of this same
33
38
  scalar under specific conditions (night + lunar phases enabled; an
34
39
  active, incomplete acclimation period) -- see "Lunar phase and
@@ -72,6 +77,75 @@ VisualID (u8) + intensity (u16 LE, 0-1000 = 0.0-100.0%)
72
77
  Confirmed against a full 9-point schedule fetched live from real XR15
73
78
  lights.
74
79
 
80
+ ## Writing a schedule
81
+
82
+ `Set`-ing `Schedule1`/`Schedule2` with every point's own encoded bytes
83
+ concatenated as one multi-element payload (same "every point is a
84
+ separate element" shape the `Get` response already uses) replaces the
85
+ whole schedule in a single request. This is `set_light_schedule()` in
86
+ `device.py`.
87
+
88
+ Two requirements, both load-bearing — get either wrong and the device
89
+ accepts the write (a normal FSCI success status) but silently leaves
90
+ the schedule unchanged, with no error surfaced anywhere:
91
+
92
+ - **Every point must define this light's full channel set**
93
+ (`get_supported_channels()`), not a subset. A partial channel set is
94
+ accepted but has no effect; the full set takes effect correctly.
95
+ - **The write must be padded to the device's own full reported
96
+ capacity** for `Schedule1`/`Schedule2` (`get_supported_attributes()`'s
97
+ own index count for that attribute), not just `len(points)` —
98
+ otherwise indexes beyond `len(points)` keep whatever was stored there
99
+ before. Padding entries are all-zero bytes (`time=0, flags=0, no
100
+ channels`); `flags` lacking `ACTIVE` (see the flags table above) is
101
+ what makes a padding entry read back as an empty slot.
102
+
103
+ `set_light_schedule()` raises `ValueError` before attempting any write
104
+ if a point is missing a required channel, or if `len(points)` itself
105
+ exceeds the reported capacity — both checkable ahead of time, so
106
+ neither needs a round-trip to the device to catch.
107
+
108
+ ## Schedule groups (`GroupBitmap`, attribute 108)
109
+
110
+ Multiple lights can share one schedule: editing one and saving pushes
111
+ the same schedule to every OTHER light whose `GroupBitmap` matches,
112
+ each as its own independent GATT write (see `Tank.java`'s own
113
+ `createSave()` call site). Not to be confused with `GroupMaster`/
114
+ `IsGroupMaster` (900/911, `GroupInfo`/`get_group_info()`) — an
115
+ unrelated, dead attribute pair with nothing to do with schedule
116
+ sharing.
117
+
118
+ A 64-bit value (`Tank.createGroupBitmap()`/`parseGroupBitmap()`/
119
+ `parseSubGroupBitmap()` in `Tank.java`): bit 0 always set, bits 1–30
120
+ a main group number (2–31), bits 31–62 an optional per-fixture
121
+ sub-group. **Matching is exact equality of the whole 64-bit value**,
122
+ not overlapping bits.
123
+
124
+ Observed values, two Radion lights on the same tank:
125
+
126
+ | State | Light A | Light B |
127
+ |---|---|---|
128
+ | Same schedule group | `0x4000000000000003` (bits 0, 1, 62) | `0x4000000000000003` (bits 0, 1, 62) |
129
+ | Split into separate groups | `0x2000000000000003` (bits 0, 1, 61) | `0x4000000000000003` (bits 0, 1, 62) |
130
+
131
+ Bit 1 (main group) is the same in both rows; only the subgroup bit (61
132
+ vs 62) differs once split — matching the bit layout above.
133
+
134
+ Every light belongs to a group in the app's own UI, including a group
135
+ of one — there is no ungrouped state exposed there. A light whose
136
+ `group_mask` (`get_device_info()`'s own field) is `None` is treated as
137
+ its own exclusive schedule group by this library, matched with no
138
+ other light regardless of that other light's own value.
139
+
140
+ `mobius.cli`'s own `--load-schedule-file`/`--load-mob-file` use this
141
+ exact-equality rule to replicate the app's own group-propagation
142
+ behavior: for a light with a real `group_mask`, both flags also write
143
+ to every other light discovered on the same Thread mesh
144
+ (`discovery.discover_tank()`) whose own `group_mask` matches exactly,
145
+ each reached via `RelayedMobiusDevice` — see `cli._resolve_group_targets()`.
146
+ A light with no `group_mask`, or a pump (which has no such grouping
147
+ concept at all), only ever writes to the one device named.
148
+
75
149
  ## Interpolation algorithm
76
150
 
77
151
  Confirmed via reverse engineering the app's own schedule-interpolation logic:
@@ -82,6 +156,10 @@ Confirmed via reverse engineering the app's own schedule-interpolation logic:
82
156
  "the next day," i.e. its time + 1440).
83
157
  3. **`Brightness` (`VisualID.Brightness`) is a master dimmer, applied to
84
158
  EACH bracketing point separately, before interpolating between them**
159
+ (the app's own UI calls this channel "Point Intensity," not
160
+ "Brightness" -- not to be confused with the separate,
161
+ whole-schedule `Schedule1Intensity` master dimmer above, which
162
+ applies once to the whole schedule rather than per-point)
85
163
  -- confirmed via reverse engineering the app's own "flatten" logic, called on
86
164
  both point copies before the
87
165
  interpolation loop -- an earlier decompilation attempt for this logic
@@ -360,3 +438,17 @@ their own attributes.
360
438
  confirmed; this separate, rarer modifier is not, and is treated as
361
439
  always-disabled by those modifiers.
362
440
  - Writing/programming a schedule (only reading is implemented).
441
+
442
+ ## One combined round-trip: `get_light_poll_batch()`
443
+
444
+ Everything on this page — the schedule fetch, and every
445
+ lunar/acclimation/insolation-related attribute
446
+ `process_light_intensities()` needs — is also available as ONE combined
447
+ round-trip via `get_light_poll_batch()`, rather than the schedule fetch
448
+ plus several further, conditional individual reads. See
449
+ [14-batched-attribute-reads.md](./14-batched-attribute-reads.md) for the
450
+ full confirmation and how it fits alongside `get_metadata_batch()`/
451
+ `get_full_poll_batch()`. The decode logic itself is identical either
452
+ way — both paths call the same pure functions this page's own formulas
453
+ describe.
454
+
@@ -0,0 +1,185 @@
1
+ # Pump schedule
2
+
3
+ ## Same attribute, same `Point` framing, different primitive and different semantics
4
+
5
+ Pumps use the exact same `Schedule1`/`Schedule2` attributes and the exact
6
+ same `time + flags + primitiveData` point framing as lights (confirmed: the
7
+ app's own support check only checks whether `Schedule1` is a supported
8
+ attribute at all — it isn't gated by `PrimitiveType`). What differs:
9
+
10
+ - `primitiveData` decodes as a **`PumpPrimitive`**, not a `LightPrimitive`.
11
+ - Semantics are **discrete blocks, not a continuous curve**. `Point.getEnd()`
12
+ in the decompile literally returns "the next point's start time" — a
13
+ point's mode and parameters are the exact active setting from its own
14
+ start time until the next point begins. There is no blending between two
15
+ pump speeds the way there is between two light-channel intensities.
16
+
17
+ This library's `get_active_pump_block()` reflects that: it's a lookup
18
+ (which point's `[start, next_start)` range contains "now"), not an
19
+ interpolation.
20
+
21
+ ## `PumpPrimitive` wire format
22
+
23
+ ```
24
+ mode (u8)
25
+ params (variable, depends on mode)
26
+ ```
27
+
28
+ ### `PumpMode` and its own parameters
29
+
30
+ Confirmed literal values (note the gap: value `11` is genuinely unused
31
+ in the source) and each mode's own full parameter set, type, and size
32
+ (byte offset within `params` is cumulative, in the listed order,
33
+ starting right after the mode byte) — everything needed to build a
34
+ valid `PumpPrimitiveValue` for that mode in one place, rather than
35
+ split across separate mode/parameter/size tables:
36
+
37
+ | Value | Mode | Parameters (type, size) |
38
+ |---|---|---|
39
+ | 0 | `Undefined` | *(none)* — the padding placeholder `set_pump_schedule()` itself writes for an empty slot |
40
+ | 1 | `ConstantSpeed` | `MaxSpeed` (int16, 2B) |
41
+ | 2 | `Lagoon` | `MaxSpeed` (int16, 2B) |
42
+ | 3 | `ReefCrest` | `MaxSpeed` (int16, 2B) |
43
+ | 4 | `NutrientTransport` | `MaxSpeed` (int16, 2B) |
44
+ | 5 | `TidalSwell` | `MaxSpeed` (int16, 2B) |
45
+ | 6 | `ShortPulse` | `MaxSpeed` (int16, 2B), `Time` (int32, 4B, **+250 offset** — see below) |
46
+ | 7 | `Gyre` | `MaxSpeed` (int16, 2B), `BigTime` (int32, 4B) |
47
+ | 8 | `Transition` | `RampType` (byte enum, 1B — see `RampType` below) |
48
+ | 9 | `ExpandingPulse` | `MaxSpeed` (int16, 2B), `StartTime` (int16, 2B, **+250 offset**), `EndTime` (int16, 2B, **+250 offset**) |
49
+ | 10 | `Sync` | `MaxSpeed` (int16, 2B), `PhaseShift` (int16, 2B), `Master` (8 raw bytes — see below) |
50
+ | 12 | `EcoSmartBack` | `MaxSpeed` (int16, 2B), `PhaseShift` (int16, 2B), `Master` (8 raw bytes) |
51
+ | 13 | `Feed` | `MaxSpeed` (int16, 2B) |
52
+ | 14 | `BatteryBackup` | `MaxSpeed` (int16, 2B) |
53
+ | 15 | `Random` | `MinSpeed` (int16, 2B), `MaxSpeed` (int16, 2B), `Variance` (int16, 2B) |
54
+ | 16 | `Pulse` | `MaxSpeed` (int16, 2B), `OnTime` (int32, 4B), `OffTime` (int32, 4B) |
55
+
56
+ All multi-byte integers are little-endian. `Master`'s own 8 bytes are
57
+ the last 8 bytes of the BLE address of the pump this one syncs to, not
58
+ a numeric field. `RampType` is a separate byte-valued enum, not a
59
+ plain integer — see its own table below.
60
+
61
+ ## Which modes a given pump actually supports
62
+
63
+ The app does not offer all 16 `PumpMode` values on every pump.
64
+ `M.PumpMode.supportedModes(PrimitiveType, Object)` (decompiled app,
65
+ `com/c2/comm/M.java`) returns a different mode list per
66
+ `PrimitiveType` (the pump's own product family, not a separate
67
+ per-model table) — and this is a genuinely enforced rule, not just a
68
+ UI display filter: `BlockSchedule.removeUnsupportedModes()` calls this
69
+ same function and actively downgrades any point whose mode isn't in
70
+ the returned list to `ConstantSpeed` before further use.
71
+
72
+ This library exposes the same logic as `supported_pump_modes()`:
73
+
74
+ | `PrimitiveType` | Supported modes |
75
+ |---|---|
76
+ | `PumpV1`, `AlpacaV1` | `ConstantSpeed`, `Random`, `Pulse`, `Sync`, `Feed` |
77
+ | `VorTechV1` | `ConstantSpeed`, `ReefCrest`, `Lagoon`, `ShortPulse`, `Feed`, `TidalSwell`, `NutrientTransport`, `Sync`, `EcoSmartBack`, `ExpandingPulse`, `Gyre`, `Transition` |
78
+ | `VectraV1`, closed-loop | `ConstantSpeed`, `ReefCrest`, `Lagoon`, `Gyre`, `Feed` |
79
+ | `VectraV1`, open-loop (or unknown) | `ConstantSpeed`, `Feed` |
80
+ | `TurtleV1`, `CoffeeV1` | `ConstantSpeed`, `Feed` |
81
+ | `Undefined`, `VisualV1`, `DoseV1`, `HotSauceV1` | *(none — this mechanism was never wired up for these)* |
82
+
83
+ `VectraV1` is the one primitive type where the result additionally
84
+ depends on a live, per-device value: whether that specific Vectra is
85
+ in "closed loop" mode (`VectraInfo.closed_loop`, from
86
+ `get_vectra_info()` — `ClosedLoop`/attribute `801`, a real settings-
87
+ screen toggle per that dataclass's own docstring, not something
88
+ inferrable from `PrimitiveType` alone). The decompiled app's own two
89
+ real call sites (`BlockInfoSliderView`, `BlockSchedule`) both use the
90
+ single-argument `supportedModes(PrimitiveType)` overload, which passes
91
+ a literal `null` through — so `supported_pump_modes()` defaults
92
+ `closed_loop` to `None` and treats that identically to `False` (the
93
+ smaller, open-loop list), matching the app's own real-world behavior
94
+ rather than guessing permissively when this value hasn't been fetched.
95
+
96
+ **`BatteryBackup`(14) is real** — a genuine `PumpMode` value with the
97
+ real, documented parameter set above — but is never returned by this
98
+ function for any `PrimitiveType`, confirmed directly from the
99
+ decompiled app's own logic. Whatever real device or app flow assigns
100
+ that mode, it isn't through this schedule-editor mode-picker path.
101
+
102
+ ### The `+250` offset quirk
103
+
104
+ `Time`, `StartTime`, and `EndTime` (**only these three** — not `OnTime`/
105
+ `OffTime`/`BigTime`, which are stored raw) are stored as
106
+ `actual_value - 250` on the wire, and the app adds `250` back when reading.
107
+ `250` is a real Android library constant
108
+ — another instance of the symbolic-substitution artifact described in
109
+ [03-attributes-and-opcodes.md](./03-attributes-and-opcodes.md), resolved by
110
+ looking up that library constant's real value. Verified against
111
+ synthetic round-trip data (`tests/test_pump_schedule.py`).
112
+
113
+ ### `MaxSpeed`/`MinSpeed` sign
114
+
115
+ Negative values encode reverse rotation direction (per `getReverse()` in
116
+ the decompile, which separately inspects the sign). This library reports
117
+ the raw signed value as-is rather than replicating the app's
118
+ sign-stripping/`getReverse()` split — check the sign yourself if direction
119
+ matters for your use case.
120
+
121
+ ### `RampType`
122
+
123
+ Confirmed literal values:
124
+
125
+ | Value | Name |
126
+ |---|---|
127
+ | 1 | `Sinusoidal` |
128
+ | 2 | `Logarithmic` |
129
+ | 3 | `Linear` |
130
+
131
+ ## Live verification
132
+
133
+ Fetched an 11-point schedule from a real VorTech MP40QD pump; the point
134
+ active at the time of the test decoded as `TidalSwell` mode with a single
135
+ `MaxSpeed` parameter (`300`) — exactly matching the parameter table above
136
+ for that mode (`TidalSwell` → `[MaxSpeed]` only), with a plausible value.
137
+
138
+ ## Avoiding a redundant re-fetch
139
+
140
+ `get_current_pump_block()` accepts an optional `points` parameter — pass
141
+ an already-fetched schedule (e.g. from a caller that already called
142
+ `get_pump_schedule()` itself for its own purposes, such as counting
143
+ points) to skip this method's own otherwise-redundant re-fetch of the
144
+ exact same attribute. `None` (the default) still fetches it fresh, same
145
+ as always.
146
+
147
+ ## Writing a pump schedule
148
+
149
+ Same mechanism as lights (see
150
+ [06-light-schedule.md](./06-light-schedule.md)'s own "Writing a
151
+ schedule" section): `Set`-ing `Schedule1`/`Schedule2` with every
152
+ point's own encoded bytes concatenated as one multi-element payload,
153
+ always padded to the device's own full reported capacity for that
154
+ attribute so a shorter write doesn't leave stale points at higher
155
+ indexes. This is `set_pump_schedule()` in `device.py`.
156
+
157
+ **Every point must also be padded to a FIXED size** —
158
+ `MAX_PUMP_SCHEDULE_POINT_SIZE` (16 bytes: 2 time + 1 flags + 1 mode +
159
+ the largest per-mode parameter total, currently `Sync`/`EcoSmartBack`'s
160
+ own `MaxSpeed`+`PhaseShift`+`Master`) — not just the longest point
161
+ within the current write. This differs from lights, which only need
162
+ padding to the longest point in that same write (see the Set-attribute
163
+ request format in
164
+ [03-attributes-and-opcodes.md](./03-attributes-and-opcodes.md): one
165
+ shared `length` field for the whole request, every element in a write
166
+ must already be the same length). A device is consistent with
167
+ expecting every schedule slot to be sized for the largest mode it can
168
+ ever hold, regardless of which mode a given point actually uses — a
169
+ short, unpadded write (e.g. a single `ConstantSpeed` point, naturally
170
+ 6 bytes) is accepted (normal FSCI success) but has no effect; the same
171
+ point padded to 16 bytes takes effect correctly.
172
+
173
+ Padding entries (for the capacity padding above) are
174
+ `PumpSchedulePoint(0, 0, PumpPrimitiveValue(PumpMode.Undefined, {}))`,
175
+ also padded out to the same fixed 16-byte size.
176
+
177
+ Unlike lights, there's no "full channel set" requirement here — each
178
+ mode's own required parameters are already strictly checked by
179
+ `PumpPrimitiveValue.encode()` itself (raises if one is missing).
180
+
181
+ ## Not implemented here
182
+
183
+ - The `Master` field's BLE-address matching against known devices in a
184
+ group (this library returns the raw 8 bytes; matching it against a scan
185
+ result's address suffix is left to the caller).
@@ -161,6 +161,21 @@ unreachable when propagation happens), or whether writing via
161
161
  same way -- see `set_time_to_now()`'s own docstring for the full
162
162
  breakdown.
163
163
 
164
+ ## Scene activation (`start_scene()`/`start_feed_mode()`) -- same mechanism, one level less confirmed
165
+
166
+ `start_scene()` defaults to `broadcast=True`, using the exact same
167
+ `group=1` reserved-byte mechanism confirmed above for `set_time_to_now()`
168
+ -- the app's own scene-activation code path sets this same group value
169
+ on every `CurrentScene` write it sends. Unlike the time-sync write,
170
+ this specific application (`CurrentScene` propagating the same way
171
+ `Epoch` does) hasn't been independently confirmed against real
172
+ hardware the same way -- it's confirmed as the app's own real code
173
+ path, not yet its own separate real-hardware propagation test. See
174
+ [05-scenes.md](./05-scenes.md) for the full picture (the wire format,
175
+ the fallback via `broadcast=False`) and the same "group=1 only reaches
176
+ this one mesh, never a different tank's own" reasoning that applies
177
+ here too.
178
+
164
179
  ## Device control (`reboot()`) -- targeted, no mesh-wide propagation
165
180
 
166
181
  `reboot()` writes `ResetType.Soft` (0) to `Reset` (6) -- confirmed via