python-mobius 0.9.0__tar.gz → 0.10.1__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 (103) hide show
  1. {python_mobius-0.9.0 → python_mobius-0.10.1}/CHANGELOG.md +29 -0
  2. {python_mobius-0.9.0 → python_mobius-0.10.1}/PKG-INFO +1 -1
  3. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/00-overview.md +2 -0
  4. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/01-ble-transport.md +5 -1
  5. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/03-attributes-and-opcodes.md +1 -0
  6. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/05-scenes.md +20 -2
  7. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/09-thread-coap-relay.md +45 -3
  8. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/11-device-status-attributes.md +13 -1
  9. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/14-attribute-reference.md +5 -5
  10. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/16-python-library.md +23 -2
  11. python_mobius-0.10.1/documentation/17-configuration-checksums.md +88 -0
  12. {python_mobius-0.9.0 → python_mobius-0.10.1}/pyproject.toml +1 -1
  13. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/__init__.py +13 -7
  14. python_mobius-0.10.1/src/mobius/checksums.py +119 -0
  15. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/cli.py +148 -19
  16. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/constants.py +19 -0
  17. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/device.py +311 -41
  18. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/device_status.py +5 -1
  19. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/discovery.py +19 -1
  20. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/manufacturer.py +6 -1
  21. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/relay.py +50 -38
  22. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/scenes.py +5 -1
  23. python_mobius-0.10.1/src/mobius/timezone.py +63 -0
  24. python_mobius-0.10.1/tests/test_bluetooth_only.py +119 -0
  25. python_mobius-0.10.1/tests/test_checksums.py +98 -0
  26. python_mobius-0.10.1/tests/test_configuration_cache.py +289 -0
  27. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_full_poll_batch.py +51 -0
  28. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_light_poll_batch.py +20 -0
  29. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_metadata_batch.py +18 -0
  30. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_relay.py +38 -19
  31. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_scenes.py +71 -0
  32. python_mobius-0.10.1/tests/test_time_zone.py +68 -0
  33. python_mobius-0.10.1/tests/test_write_size.py +64 -0
  34. {python_mobius-0.9.0 → python_mobius-0.10.1}/.forgejo/scripts/extract_changelog_section.py +0 -0
  35. {python_mobius-0.9.0 → python_mobius-0.10.1}/.forgejo/workflows/release.yml +0 -0
  36. {python_mobius-0.9.0 → python_mobius-0.10.1}/.gitignore +0 -0
  37. {python_mobius-0.9.0 → python_mobius-0.10.1}/LICENSE +0 -0
  38. {python_mobius-0.9.0 → python_mobius-0.10.1}/README.md +0 -0
  39. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/02-framing-and-crc.md +0 -0
  40. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/04-device-identity.md +0 -0
  41. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/06-light-schedule.md +0 -0
  42. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/07-pump-schedule.md +0 -0
  43. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/08-manufacturer-data.md +0 -0
  44. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/10-known-gaps-and-open-questions.md +0 -0
  45. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/12-device-identity-and-address-stability.md +0 -0
  46. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/13-attribute-dump.md +0 -0
  47. {python_mobius-0.9.0 → python_mobius-0.10.1}/documentation/15-mob-template-format.md +0 -0
  48. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/coap.py +0 -0
  49. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/crc.py +0 -0
  50. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/dump.py +0 -0
  51. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/frame.py +0 -0
  52. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/mesh_address.py +0 -0
  53. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/mob.py +0 -0
  54. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/modifiers.py +0 -0
  55. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/power.py +0 -0
  56. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/pump_status.py +0 -0
  57. {python_mobius-0.9.0 → python_mobius-0.10.1}/src/mobius/schedule.py +0 -0
  58. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_attributes_batch.py +0 -0
  59. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_battery_settings.py +0 -0
  60. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_cli_group_targets.py +0 -0
  61. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_cli_mob_file.py +0 -0
  62. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_cli_schedule_file.py +0 -0
  63. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_coap.py +0 -0
  64. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_coap_indication_handling.py +0 -0
  65. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_connection_cleanup.py +0 -0
  66. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_device_control.py +0 -0
  67. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_device_status.py +0 -0
  68. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_device_summary.py +0 -0
  69. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_device_time.py +0 -0
  70. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_discovery_company_ids.py +0 -0
  71. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_dump.py +0 -0
  72. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_extra_physical_values.py +0 -0
  73. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_fan_thresholds.py +0 -0
  74. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_firmware_versions.py +0 -0
  75. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_frame.py +0 -0
  76. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_frame_sniffer.py +0 -0
  77. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_group_bitmap.py +0 -0
  78. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_hyperdrive_device.py +0 -0
  79. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_insolation_detection.py +0 -0
  80. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_light_intensity_branching.py +0 -0
  81. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_light_schedule.py +0 -0
  82. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_manufacturer.py +0 -0
  83. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_manufacturer_lookup.py +0 -0
  84. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_mesh_address.py +0 -0
  85. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_mesh_discovery.py +0 -0
  86. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_mesh_discovery_direct_connect.py +0 -0
  87. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_mob.py +0 -0
  88. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_modifiers.py +0 -0
  89. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_networked_thread_devices.py +0 -0
  90. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_new_diagnostic_attributes.py +0 -0
  91. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_power.py +0 -0
  92. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_pump_param_ranges.py +0 -0
  93. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_pump_reverse.py +0 -0
  94. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_pump_schedule.py +0 -0
  95. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_pump_status.py +0 -0
  96. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_pump_supported_modes.py +0 -0
  97. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_pump_telemetry.py +0 -0
  98. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_schedule_file_format.py +0 -0
  99. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_schedule_intensity_scalar.py +0 -0
  100. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_serial_decoding.py +0 -0
  101. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_serial_identity.py +0 -0
  102. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_shared_helpers.py +0 -0
  103. {python_mobius-0.9.0 → python_mobius-0.10.1}/tests/test_supported_attributes.py +0 -0
@@ -1,5 +1,34 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.10.1
4
+
5
+ - Fixed lights not being read through a gateway: the poll's reply was too
6
+ large to be relayed. The schedule and scenes are now read in requests
7
+ of their own, as the app does.
8
+
9
+ ## 0.10.0
10
+
11
+ - Added `set_time_zone()`; the batched polls now include the device's time
12
+ zone strings.
13
+ - Relayed requests now accept a late response to the first attempt, as
14
+ the app does.
15
+ - Fixed an unreachable light returning an empty poll as a success.
16
+ - Added configuration checksums (`get_configuration_checksums()`, CLI
17
+ `--check-checksums`).
18
+ - `get_full_poll_batch(cache=...)` reads scenes and schedules only when
19
+ their checksum changed.
20
+ - `start_scene()` now runs a scene for its configured length by default
21
+ (a duration of 0 makes the device reuse its last scene length).
22
+ - Fixed a scene briefly reporting about 18 hours remaining as it ends.
23
+ - Added Bluetooth-only device support (Blades, MXM): `is_bluetooth_only()`
24
+ and `find_bluetooth_only_tank_members()`. The CLI connects to them
25
+ directly and includes them in schedule group writes.
26
+ - Writes use the negotiated MTU instead of 20-byte fragments, as the app
27
+ does (fewer writes per request).
28
+ - The pump's operation state is now part of the batched poll.
29
+ - `ConfigurationCache(static_refresh_interval=...)` reads rarely-changing
30
+ attributes (identity, firmware, settings) only at that interval.
31
+
3
32
  ## 0.9.0
4
33
 
5
34
  - **Breaking:** `RelayedMobiusDevice` writes now raise an error when the
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-mobius
3
- Version: 0.9.0
3
+ Version: 0.10.1
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
@@ -27,6 +27,7 @@ and the `danmrossi/MobiusControl` project) and testing on real devices.
27
27
  | Reach many devices through one BLE connection | [09](./09-thread-coap-relay.md) |
28
28
  | Read settings, diagnostics or send commands (reboot, set time) | [11](./11-device-status-attributes.md) |
29
29
  | Dump every attribute of a device | [13](./13-attribute-dump.md) |
30
+ | Avoid re-reading scenes, schedules or the attribute table | [17](./17-configuration-checksums.md) |
30
31
  | Know what is not covered or not tested | [10](./10-known-gaps-and-open-questions.md) |
31
32
 
32
33
  ## Document map
@@ -49,6 +50,7 @@ and the `danmrossi/MobiusControl` project) and testing on real devices.
49
50
  | [14-attribute-reference.md](./14-attribute-reference.md) | Every known attribute ID and enum value |
50
51
  | [15-mob-template-format.md](./15-mob-template-format.md) | The app's `.mob` schedule template file |
51
52
  | [16-python-library.md](./16-python-library.md) | How this Python library maps onto the protocol; CLI |
53
+ | [17-configuration-checksums.md](./17-configuration-checksums.md) | Checksums of the attribute table, scenes and schedules |
52
54
 
53
55
  ## Conventions
54
56
 
@@ -31,7 +31,11 @@ an `RX_FINAL` notification appends its bytes and completes the frame. Parse
31
31
  the buffer and clear it.
32
32
 
33
33
  20 bytes (the BLE 4.0 default ATT payload) always works; a larger
34
- negotiated MTU only reduces the number of writes.
34
+ negotiated MTU only reduces the number of writes. The app asks for an MTU
35
+ of 500 and writes chunks of the negotiated MTU minus 5; `MobiusDevice`
36
+ does the same with the MTU the Bluetooth stack negotiated (20 bytes when
37
+ it's unknown or 23), unless given a fixed `mtu`. Over a Bluetooth proxy
38
+ every write is a network round trip, so this matters most there.
35
39
 
36
40
  ## Several requests in flight
37
41
 
@@ -126,6 +126,7 @@ that device. The app uses:
126
126
 
127
127
  | Write | Attribute | Group |
128
128
  |---|---|---|
129
+ | Set the time zone | `PosixTimeZoneString` (205), `OlsonTimeZoneLocation` (206) | 1 |
129
130
  | Set the clock | `Epoch` (201) = current Unix time, u32 | 1. Rejected with group 0 |
130
131
  | Start a scene | `CurrentScene` (401) | The tank's group ([05](./05-scenes.md)) |
131
132
  | Cancel a scene | `OperationState` (104) = 3 | The tank's group ([05](./05-scenes.md#cancelling-a-scene)) |
@@ -58,7 +58,9 @@ SceneTimer (403): remaining seconds (u16)
58
58
  SceneTimeout (402): total seconds of the running scene (u16)
59
59
  ```
60
60
 
61
- A scene is running when the id is not 0 and `SceneTimer` is above 0. The
61
+ A scene is running when the id is not 0 and `SceneTimer` is above 0 (and
62
+ not above `SceneTimeout`; see below). Right after a scene is started,
63
+ `SceneTimer` can still read 0 for a moment. The
62
64
  second half of `CurrentScene` doesn't change while the scene runs; the
63
65
  countdown is `SceneTimer`. The app reads these three attributes once and
64
66
  counts the remaining time down itself (end time = now + `SceneTimer`).
@@ -70,16 +72,32 @@ Example (idle VorTech pump): `CurrentScene` `00 00 00 00`, `SceneTimer`
70
72
  Write `CurrentScene`:
71
73
 
72
74
  ```
73
- id (u16) + duration (u16, seconds; 0 = use the scene's own timeout)
75
+ id (u16) + duration (u16, seconds)
74
76
  ```
75
77
 
78
+ A duration of 0 makes the device reuse the length of its last scene run
79
+ (`SceneTimeout`), not the scene's configured timeout: on a VorTech whose
80
+ Feed Mode is configured for 300 s, starting Feed Mode with 20 and later
81
+ with 0 ran it for 20 s both times. `start_scene()` therefore writes the
82
+ scene's configured timeout from `ConfiguredScenes` unless a duration is
83
+ given.
84
+
76
85
  The app writes 0 as the duration and sends the write to one device with
77
86
  the reserved field set to the **tank's group**
78
87
  ([02](./02-framing-and-crc.md#reserved-field)), so every device of the tank
79
88
  starts the scene. With group 0 only the addressed device changes.
89
+ Bluetooth-only devices (Blades) are not on the mesh, so the group write
90
+ doesn't reach them; the app sends them the same write directly
91
+ ([09](./09-thread-coap-relay.md#bluetooth-only-devices)).
80
92
 
81
93
  To add time to the running scene, the app writes the same id with
82
94
  `remaining + extra` seconds (at most 65535), also to the tank's group.
95
+ Written with group 0, the same write doesn't change the running scene
96
+ (the countdown continues); with the tank's group it's untested.
97
+
98
+ As a scene ends, `SceneTimer` can briefly read just below 65536 (the
99
+ timer passing zero); a `SceneTimer` above `SceneTimeout` means the scene
100
+ has ended.
83
101
 
84
102
  ## Cancelling a scene
85
103
 
@@ -143,9 +143,12 @@ Request types: 0 Con, 1 Non, 2 Ack, 3 Rst. Methods: 1 GET, 2 POST, 3 PUT,
143
143
 
144
144
  ### Timeouts and retries
145
145
 
146
- Use a timeout of a few seconds (5 s works) per attempt and retry once with
147
- a new token: a single lost relay round trip is common. When both attempts
148
- time out, the route to that target is probably down for a while; failing
146
+ Wait 5 s for the response. On a timeout, send the same request again
147
+ with the **same token** and wait another 5 s; a response to either send
148
+ completes the request. This is what the app does, and it matters: a
149
+ relayed response can arrive after the first 5 s, and with a new token for
150
+ the resend that late response would be discarded. When both waits time
151
+ out, the route to that target is probably down for a while; failing
149
152
  further requests to it immediately until the next poll cycle avoids
150
153
  waiting on every one.
151
154
 
@@ -153,8 +156,47 @@ A gateway can lose its route to a single target while everything else
153
156
  keeps working. The protocol has no command to rebuild the mesh; switching
154
157
  to another device as gateway is the practical recovery.
155
158
 
159
+ ## Reply size
160
+
161
+ A relayed reply that is too large never arrives: the request times out
162
+ as if the target were unreachable, while smaller requests to the same
163
+ target keep working. Seen with a Radion XR15 G6 relayed through a VorTech
164
+ MP40: a poll batch whose reply was about 2 KB (schedule, scenes and
165
+ metadata together) always timed out, the same data read in three requests
166
+ arrived. The limit is somewhere above 1.1 KB (a 25-point light schedule
167
+ alone, about 1.05 KB of data, arrives).
168
+
169
+ The app reads the schedule (and the Insolation schedule and driver
170
+ information) in a request of its own, and everything else together; the
171
+ Insolation table even takes 8 requests. `get_full_poll_batch()` does the
172
+ same with the schedule and `ConfiguredScenes`.
173
+
156
174
  ## Writes and the mesh
157
175
 
158
176
  A relayed Set reaches only its target. A Set sent with the reserved
159
177
  field's group = 1 is forwarded by the receiving device to the whole mesh
160
178
  instead; see [03](./03-attributes-and-opcodes.md#group-writes).
179
+
180
+ ## Bluetooth-only devices
181
+
182
+ Some devices share a tank's pan_id in their advertisement but are not on
183
+ its Thread mesh: they have no mesh address, don't appear in any device's
184
+ `NetworkedThreadDevices`, and can't be relayed to. They are the Blade
185
+ models and MXM (`BLUETOOTH_ONLY_MODELS`, `is_bluetooth_only()`; the app
186
+ keeps the same list). A Blade reports `RadioType` ESP32C3.
187
+
188
+ The app handles them as follows:
189
+
190
+ - A request to a Bluetooth-only device always goes over that device's own
191
+ connection, never through the gateway.
192
+ - A Bluetooth-only device is never chosen as the device to connect to for
193
+ the rest of the tank, unless the tank has no other device.
194
+ - A tank-wide write (scenes, cancelling a scene, time sync, restart all)
195
+ is sent once through a mesh device with the tank's group, which reaches
196
+ the mesh, and once more directly to each Bluetooth-only device. A group
197
+ write sent over a Bluetooth-only device's connection reaches only that
198
+ device.
199
+
200
+ `find_bluetooth_only_tank_members(pan_id)` lists the Bluetooth-only
201
+ devices advertising a tank's pan_id. `RelayedMobiusDevice` refuses a peer
202
+ whose model is Bluetooth-only.
@@ -131,7 +131,18 @@ encoding unknown. Read them as raw bytes.
131
131
  | `RTCTime` | 219 | u32 Unix seconds from the real-time clock |
132
132
  | `IsClockSet` | 200 | bool |
133
133
 
134
- The device's local date is the UTC date of `LocalTime`. For lunar
134
+ The device's local date is the UTC date of `LocalTime`. The device
135
+ derives `LocalTime` from `Epoch` and its POSIX TZ string (205), so a wrong
136
+ time zone shifts schedules and the lunar date.
137
+
138
+ **How the app keeps the clock right:** it doesn't sync on a timer. When it
139
+ loads a tank it compares the tank's `Epoch` with the phone's clock, and if
140
+ they differ by more than 15 minutes it shows "Your device's clock is
141
+ incorrect", which the user taps to fix (unless the time was deliberately
142
+ set by hand). The fix writes the time zone (205 and 206, from the phone's
143
+ time zone), then `Epoch`, all with group 1. The POSIX string of an Olson
144
+ zone name is the last line of its TZif file (for example `Europe/Lisbon`
145
+ → `WET0WEST,M3.5.0/1,M10.5.0`). For lunar
135
146
  calculations see [06](./06-light-schedule.md#the-lunar-date).
136
147
 
137
148
  The app's "Time Difference" troubleshooting check compares `Epoch` and
@@ -142,6 +153,7 @@ the schedule computation, which uses the client's clock.
142
153
 
143
154
  | Command | Write | Reserved group |
144
155
  |---|---|---|
156
+ | Set the time zone | `PosixTimeZoneString` (205) and `OlsonTimeZoneLocation` (206), each a 40-byte zero-padded string | 1 |
145
157
  | Set the time | `Epoch` (201) = current Unix time (u32) | **1** (required; forwarded to the whole tank) |
146
158
  | Reboot one device | `Reset` (6) = 0 (Soft) | 0 |
147
159
  | Battery backup speed | `BatteryBackupSpeed` (706) = i16 permille | 0 |
@@ -23,9 +23,9 @@ Integers are little-endian; see the conventions in
23
23
  | 9 | `NumberOfFsciInstances` | u8 | |
24
24
  | 10 | `MaxFSCIPacketSize` | u16 | |
25
25
  | 11 | `BootCounter` | u32 | Boot counter |
26
- | 12 | `CRCAttributes` | — | Reads as 32 × 0xFFFF on every device seen |
26
+ | 12 | `CRCAttributes` | — | Reads as 32 × 0xFFFF on every device seen. [17](./17-configuration-checksums.md#crcattributes-12-and-crcfunction-13) |
27
27
  | 13 | `CRCFunction` | u16 | |
28
- | 14 | `SupportedAttributesCRC` | u16 | |
28
+ | 14 | `SupportedAttributesCRC` | u16 | Checksum of the supported attribute table. [17](./17-configuration-checksums.md#supportedattributescrc-14) |
29
29
  | 15 | `NumberOfPrimitives` | u8 | |
30
30
  | 101 | `PhysicalValues` | indexed by PhysicalValueID; element = i32 (4 bytes) or raw | Supported indexes differ per device. [11](./11-device-status-attributes.md#pump-telemetry) |
31
31
  | 102 | `PowerState` | u8 | |
@@ -76,12 +76,12 @@ Integers are little-endian; see the conventions in
76
76
  | 405 | `LiveDemoTimeout` | u16 | |
77
77
  | 406 | `LiveDemoTimer` | u16 | |
78
78
  | 407 | `LiveDemoScene` | raw | Length differs between lights and pumps |
79
- | 408 | `ConfiguredScenesChecksum` | u16 | |
79
+ | 408 | `ConfiguredScenesChecksum` | u16 | CRC16 of the used scene slots. [17](./17-configuration-checksums.md#configuredsceneschecksum-408) |
80
80
  | 500 | `Schedule1` | list of schedule points | [06](./06-light-schedule.md), [07](./07-pump-schedule.md). Index count = capacity |
81
- | 501 | `Schedule1Checksum` | — | |
81
+ | 501 | `Schedule1Checksum` | u16 | CRC16 of the active points. [17](./17-configuration-checksums.md#schedule1checksum-501-schedule2checksum-504) |
82
82
  | 502 | `Schedule1StartTime` | — | |
83
83
  | 503 | `Schedule2` | list of schedule points | Insolation moon schedule (lights only); same format as 500 ([06](./06-light-schedule.md#moon-schedule-schedule2)) |
84
- | 504 | `Schedule2Checksum` | — | |
84
+ | 504 | `Schedule2Checksum` | u16 | CRC16 of the active points. [17](./17-configuration-checksums.md#schedule1checksum-501-schedule2checksum-504) |
85
85
  | 505 | `Schedule2StartTime` | — | |
86
86
  | 506 | `Schedule2ActiveDays` | — | |
87
87
  | 507 | `CurrentSchedule` | — | |
@@ -18,6 +18,7 @@ documents. The README has installation and a quick start.
18
18
  | `mobius.device_status`, `mobius.pump_status` | Result dataclasses ([11](./11-device-status-attributes.md)) |
19
19
  | `mobius.mob` | `.mob` templates ([15](./15-mob-template-format.md)) |
20
20
  | `mobius.dump` | Attribute dumps ([13](./13-attribute-dump.md)) |
21
+ | `mobius.checksums` | Configuration checksums ([17](./17-configuration-checksums.md)) |
21
22
  | `mobius.cli` | The `mobius-scan` command |
22
23
 
23
24
  ## Connecting
@@ -57,8 +58,9 @@ Higher level, by area:
57
58
  | Pumps | `get_pump_telemetry()`, `get_pump_flow_range()`, `get_pump_schedule()`, `set_pump_schedule()`, `get_current_pump_block()`, `get_pump_override_mode()`, `get_operation_mode()`, `get_battery_backup_info()`, `set_battery_backup_speed()`, `get_boosted_battery_info()`, `set_boosted_battery()`, `get_vectra_info()`, `get_coffee_info()` |
58
59
  | Scenes | `get_configured_scenes()`, `get_current_scene()`, `start_scene()`, `start_feed_mode()`, `resume_schedule()`, `get_operation_state()`, `get_tank_group()` |
59
60
  | Settings | `get_advanced_features()`, `set_advanced_features()`, `get_calibration_info()`, `get_maintenance_info()`, `get_group_info()`, `get_fan_thresholds()` |
60
- | Commands | `set_time_to_now()`, `reboot()`, `reboot_all()` |
61
+ | Commands | `set_time_to_now()`, `set_time_zone()`, `reboot()`, `reboot_all()` |
61
62
  | Clock | `get_device_time_info()`, `get_clock_sync_info()` |
63
+ | Checksums | `get_configuration_checksums()` |
62
64
  | Mesh | `get_own_mesh_address()`, `discover_networked_thread_devices()`, `discover_mesh_peers()`, `discover_mesh_peers_auto()` |
63
65
  | Diagnostics | `get_fsci_diagnostic_info()`, `get_thread_provisioning_info()`, `get_thread_network_state_info()`, `get_scene_diagnostic_info()`, `get_legacy_device_info()`, `get_calibration_extra_info()`, `get_ble_connection_info()`, `get_wifi_remnant_info()`, `get_engineering_diagnostic_info()`, `get_channel_intensities()`, `get_extra_physical_values()` |
64
66
 
@@ -88,6 +90,21 @@ Pass `supported_attribute_ids` (read once with
88
90
  `get_full_poll_batch()` needs the `PrimitiveType` and `Model` from a first
89
91
  `get_device_info()`; after that one request per poll is enough.
90
92
 
93
+ Pass a `ConfigurationCache` (one per device, kept between polls) as
94
+ `get_full_poll_batch(cache=...)` to read the scenes and the schedule only
95
+ when their checksum changed
96
+ ([17](./17-configuration-checksums.md#polling-with-the-checksums)).
97
+ `configuration_read` on the result lists what was read, and
98
+ `attribute_table_changed` reports a changed `SupportedAttributesCRC`, after
99
+ which the supported attributes should be read again.
100
+
101
+ Setting the cache's `static_refresh_interval` (seconds) also limits how
102
+ often the attributes that rarely change are read (`STATIC_POLL_ATTRIBUTES`:
103
+ identity, firmware and hardware, color channels, calibration, advanced
104
+ settings, pump flow range and battery settings); in between they come from
105
+ the cache. After writing one of them, call `cache.invalidate_static()`;
106
+ `cache.clear()` makes the next poll read everything.
107
+
91
108
  ## Relaying
92
109
 
93
110
  ```python
@@ -117,6 +134,9 @@ tank including the gateway itself.
117
134
  | `light_schedule_to_mob()`, `mob_to_light_schedule()`, `pump_schedule_to_mob()`, `mob_to_pump_schedule()` | `.mob` templates |
118
135
  | `dump_attributes()`, `format_attribute_dump_json()`, `parse_attribute_dump()` | Attribute dumps |
119
136
  | `format_mesh_address()` | IPv6 text of a mesh address |
137
+ | `is_bluetooth_only()`, `BLUETOOTH_ONLY_MODELS`, `find_bluetooth_only_tank_members()` | Devices of a tank that aren't on its mesh ([09](./09-thread-coap-relay.md#bluetooth-only-devices)) |
138
+ | `supported_attributes_crc()`, `configured_scenes_checksum()`, `schedule_checksum()` | Configuration checksums computed from the data |
139
+ | `posix_tz_string()` | POSIX TZ string of an Olson time zone name |
120
140
 
121
141
  ## Command line: `mobius-scan`
122
142
 
@@ -138,12 +158,13 @@ With `--by-serial SERIAL` (connects to one device):
138
158
  |---|---|
139
159
  | `--dump-schedule`, `--dump-scenes` | Print the schedule and the scene slots |
140
160
  | `--dump-mesh-peers` | Mesh discovery, step by step |
141
- | `--relay-target SERIAL` | Reach another device through this one; the options below then act on it |
161
+ | `--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)) |
142
162
  | `--debug-relay` | Print every layer of each relayed request |
143
163
  | `--set-time-to-now`, `--reboot` | Commands |
144
164
  | `--set-local-control-enabled`, `--set-auto-dim-timeout`, `--set-max-fan-speed`, `--set-fan-shutdown-enabled` | Advanced features |
145
165
  | `--set-battery-backup-speed`, `--set-boosted-battery-power`, `--set-boosted-battery-on-time`, `--set-boosted-battery-off-time` | Pump battery settings |
146
166
  | `--dump-attributes FILE` | Attribute dump (JSON) |
167
+ | `--check-checksums` | Compare the device's configuration checksums with their computation ([17](./17-configuration-checksums.md)) |
147
168
  | `--dump-schedule-file FILE` / `--load-schedule-file FILE` | Save / load both schedules as editable JSON |
148
169
  | `--dump-mob-file FILE` / `--load-mob-file FILE` (`--mob-template-name`, `--mob-which`) | Save / load a `.mob` template |
149
170
 
@@ -0,0 +1,88 @@
1
+ # Configuration checksums
2
+
3
+ A device reports 2-byte checksums of parts of its configuration. Reading a
4
+ checksum instead of the data it covers lets a client re-read the data only
5
+ when it changed.
6
+
7
+ | Attribute | ID | Covers |
8
+ |---|---|---|
9
+ | `SupportedAttributesCRC` | 14 | The supported attribute table ([03](./03-attributes-and-opcodes.md#listing-supported-attributes)) |
10
+ | `ConfiguredScenesChecksum` | 408 | `ConfiguredScenes` (400) |
11
+ | `Schedule1Checksum` | 501 | `Schedule1` (500) |
12
+ | `Schedule2Checksum` | 504 | `Schedule2` (503) |
13
+
14
+ ## SupportedAttributesCRC (14)
15
+
16
+ The app keeps the supported attribute table of every device and computes
17
+ this checksum over it:
18
+
19
+ ```
20
+ crc16 over, for each attribute in the order the device lists them:
21
+ id (u16) + number of indexes (u8) + each index (u8)
22
+ ```
23
+
24
+ ([CRC16](./02-framing-and-crc.md#crc16), seed 0xFFFF; counts and indexes above
25
+ 255 keep only their low byte.) When the device's value differs, the app
26
+ reads the table again, and then every cached feature. The app also reads
27
+ it again after a firmware update. The table is a property of the
28
+ firmware, so the checksum is expected to change only with a firmware
29
+ update. The device's value is the same computation.
30
+
31
+ ## ConfiguredScenesChecksum (408)
32
+
33
+ ```
34
+ crc16 (seed 0xFFFF) over the used slots of ConfiguredScenes (id != 0),
35
+ each slot's bytes as read, in slot order
36
+ ```
37
+
38
+ Changes when a scene is edited in the app.
39
+
40
+ ## Schedule1Checksum (501), Schedule2Checksum (504)
41
+
42
+ ```
43
+ crc16 (seed 0xFFFF) over the active points of Schedule1 / Schedule2
44
+ (flags bit 0 set), each point's bytes as read, in order
45
+ ```
46
+
47
+ `Schedule1Intensity` / `Schedule2Intensity` are not included. A schedule
48
+ with no active points (an unused moon schedule, for example) reports
49
+ 0xFFFF, the CRC16 of no data.
50
+
51
+ All four computations are confirmed on a VorTech MP40 and a Radion XR15
52
+ G6. `mobius --by-serial SERIAL --check-checksums` compares a device's
53
+ values with them.
54
+
55
+ ## Polling with the checksums
56
+
57
+ To read scenes and a schedule only when they changed:
58
+
59
+ 1. Read the data together with its checksum in one request, and keep
60
+ both.
61
+ 2. On each later poll, read only the checksum (2 bytes). If it equals the
62
+ kept one, the kept data is current.
63
+ 3. If it differs, or can't be read, read the data and its checksum again.
64
+
65
+ Read each data attribute in a request of its own: a light's schedule or
66
+ scenes are close to the largest reply a relay carries
67
+ ([09](./09-thread-coap-relay.md#reply-size)).
68
+
69
+ The key is the device's own value, so this works without computing
70
+ anything and also catches changes made by other clients (the app, or
71
+ this client's own writes). Include `SupportedAttributesCRC` in the poll: a
72
+ new value means a firmware update, after which the supported attributes
73
+ should be read again.
74
+
75
+ ## What the app does with these
76
+
77
+ The app reads none of 408, 501 and 504. It keeps scenes and schedules on
78
+ the phone and reads them again only when it has no copy, has unsaved
79
+ changes of its own, or after the attribute table changed; changes made by
80
+ another client are not noticed.
81
+
82
+ ## CRCAttributes (12) and CRCFunction (13)
83
+
84
+ `CRCAttributes` reads as 32 × 0xFFFF on every device seen. The app has
85
+ code that writes a list of attribute IDs (padded to 32 with 0xFFFF) to
86
+ `CRCAttributes` together with a u16 to `CRCFunction`, which looks like a
87
+ device-side check of a checksum over those attributes, but never calls
88
+ it.
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-mobius"
7
- version = "0.9.0"
7
+ version = "0.10.1"
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" }
@@ -9,6 +9,10 @@ documentation/ for the protocol description.
9
9
  """
10
10
 
11
11
  from .crc import crc16, CRC16_TABLE
12
+ from .checksums import (
13
+ ConfigurationCache, ConfigurationChecksums, configured_scenes_checksum, schedule_checksum,
14
+ supported_attributes_crc,
15
+ )
12
16
  from .frame import (
13
17
  build_frame, parse_frame, ParsedFrame,
14
18
  encode_get_attribute, encode_get_attributes_batch, encode_set_attribute, decode_attribute_response, AttributeValue,
@@ -22,7 +26,7 @@ from .constants import (
22
26
  PrimitiveType, PRIMITIVE_SIZE, LIGHT_PRIMITIVES,
23
27
  PUMP_PRIMITIVES_VERIFIED, PUMP_PRIMITIVES_EXPERIMENTAL, PUMP_PRIMITIVES, enum_or_none,
24
28
  primitive_type_from_name, support_tier, PumpParamRange, pump_param_range, pump_param_errors,
25
- Model, MODEL_MANUFACTURER, manufacturer_for_model,
29
+ Model, MODEL_MANUFACTURER, manufacturer_for_model, BLUETOOTH_ONLY_MODELS, is_bluetooth_only,
26
30
  ErrorState, C2Attribute, OperationMode, PhysicalValueID, VisualID,
27
31
  SceneID, OperationState, FsciStatus,
28
32
  PumpMode, PumpOverrideMode, RampType, PumpParam, PUMP_PARAM_SIZE, PUMP_MODE_PARAMS,
@@ -68,9 +72,10 @@ from .device import (
68
72
  from .discovery import (
69
73
  scan_for_mobius_devices, scan_for_mobius_devices_with_info, group_by_pan_id,
70
74
  dedupe_by_serial, find_device_by_serial, discover_mesh_peers_via_direct_connect,
71
- discover_tank,
75
+ discover_tank, find_bluetooth_only_tank_members,
72
76
  )
73
77
  from .frame import SupportedAttribute
78
+ from .timezone import posix_tz_string, TIME_ZONE_FIELD_SIZE
74
79
  from .dump import (
75
80
  dump_attributes, format_attribute_dump_json, parse_attribute_dump_json,
76
81
  format_attribute_dump_text, parse_attribute_dump_text, parse_attribute_dump,
@@ -82,12 +87,13 @@ from .mob import (
82
87
  MAX_PUMP_PRIMITIVE_SIZE,
83
88
  )
84
89
 
85
- __version__ = "0.9.0"
90
+ __version__ = "0.10.1"
86
91
 
87
92
  __all__ = [
88
93
  "__version__",
89
94
  # crc
90
- "crc16", "CRC16_TABLE",
95
+ "crc16", "CRC16_TABLE", "supported_attributes_crc", "configured_scenes_checksum",
96
+ "schedule_checksum", "ConfigurationChecksums", "ConfigurationCache",
91
97
  # frame
92
98
  "build_frame", "parse_frame", "ParsedFrame",
93
99
  "encode_get_attribute", "encode_get_attributes_batch", "encode_set_attribute", "decode_attribute_response", "AttributeValue",
@@ -100,7 +106,7 @@ __all__ = [
100
106
  "PrimitiveType", "PRIMITIVE_SIZE", "LIGHT_PRIMITIVES",
101
107
  "PUMP_PRIMITIVES_VERIFIED", "PUMP_PRIMITIVES_EXPERIMENTAL", "PUMP_PRIMITIVES", "enum_or_none",
102
108
  "primitive_type_from_name", "support_tier", "PumpParamRange", "pump_param_range", "pump_param_errors",
103
- "Model", "MODEL_MANUFACTURER", "manufacturer_for_model",
109
+ "Model", "MODEL_MANUFACTURER", "BLUETOOTH_ONLY_MODELS", "is_bluetooth_only", "manufacturer_for_model",
104
110
  "ErrorState", "C2Attribute", "OperationMode", "PhysicalValueID", "VisualID",
105
111
  "SceneID", "OperationState", "FsciStatus",
106
112
  "PumpMode", "PumpOverrideMode", "RampType", "PumpParam", "PUMP_PARAM_SIZE", "PUMP_MODE_PARAMS",
@@ -134,7 +140,7 @@ __all__ = [
134
140
  "next_coap_token",
135
141
  "mesh_local_prefix_from_own_address", "mesh_local_prefix_from_mlprefix", "is_valid_mesh_address",
136
142
  "build_rloc_address", "extract_short_address", "is_short_address_derived",
137
- "format_mesh_address",
143
+ "format_mesh_address", "posix_tz_string", "TIME_ZONE_FIELD_SIZE",
138
144
  "RelayedMobiusDevice",
139
145
  # device
140
146
  "MobiusDevice", "MobiusPump", "LightIntensityResult", "LightPollResult", "FullPollResult",
@@ -143,7 +149,7 @@ __all__ = [
143
149
  # discovery
144
150
  "scan_for_mobius_devices", "scan_for_mobius_devices_with_info", "group_by_pan_id",
145
151
  "dedupe_by_serial", "find_device_by_serial", "discover_mesh_peers_via_direct_connect",
146
- "discover_tank",
152
+ "find_bluetooth_only_tank_members", "discover_tank",
147
153
  "SupportedAttribute", "dump_attributes", "enrich_attribute_dump", "generic_interpretations",
148
154
  "format_attribute_dump_json", "parse_attribute_dump_json",
149
155
  "format_attribute_dump_text", "parse_attribute_dump_text", "parse_attribute_dump",
@@ -0,0 +1,119 @@
1
+ """Checksums a device reports about its own configuration, and how to
2
+ compute them. Reading a 2-byte checksum instead of the data it covers lets
3
+ a client skip re-reading data that hasn't changed.
4
+
5
+ | Attribute | ID | Covers |
6
+ |---|---|---|
7
+ | `SupportedAttributesCRC` | 14 | The supported attribute table (see supported_attributes_crc()) |
8
+ | `ConfiguredScenesChecksum` | 408 | ConfiguredScenes (400) |
9
+ | `Schedule1Checksum` | 501 | Schedule1 (500) |
10
+ | `Schedule2Checksum` | 504 | Schedule2 (503) |
11
+
12
+ All four are CRC16 with seed 0xFFFF (see the functions below for what each
13
+ covers), confirmed on a VorTech MP40 and a Radion XR15 G6. The app
14
+ computes SupportedAttributesCRC itself and compares it with the device's
15
+ value to decide whether to read the attribute table again; the other
16
+ three it doesn't read.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import struct
22
+ from dataclasses import dataclass, field
23
+ from typing import Iterable, Optional
24
+
25
+ from .crc import crc16
26
+ from .frame import AttributeValue, SupportedAttribute
27
+
28
+
29
+ def supported_attributes_crc(supported: Iterable[SupportedAttribute]) -> int:
30
+ """
31
+ The app's checksum of a supported attribute table, for comparison with
32
+ SupportedAttributesCRC (14): crc16 over, for each attribute in the
33
+ order the device lists them, the id (u16 LE), the number of indexes
34
+ (u8) and each index (u8). Counts and indexes above 255 are truncated
35
+ to their low byte, as the app does.
36
+ """
37
+ data = bytearray()
38
+ for attribute in supported:
39
+ data += struct.pack("<H", attribute.attr_id)
40
+ data.append(len(attribute.indexes) & 0xFF)
41
+ data.extend(index & 0xFF for index in attribute.indexes)
42
+ return crc16(bytes(data))
43
+
44
+
45
+ def configured_scenes_checksum(slots: Iterable[bytes]) -> int:
46
+ """ConfiguredScenesChecksum (408) of ConfiguredScenes (400) slots as
47
+ read: crc16 over the used slots (scene id != 0) in slot order."""
48
+ return crc16(b"".join(slot for slot in slots if len(slot) >= 2 and struct.unpack_from("<H", slot)[0] != 0))
49
+
50
+
51
+ def schedule_checksum(points: Iterable[bytes]) -> int:
52
+ """Schedule1Checksum (501) / Schedule2Checksum (504) of Schedule1 (500) /
53
+ Schedule2 (503) points as read: crc16 over the active points (flags
54
+ bit 0 set) in order. Schedule1Intensity/Schedule2Intensity are not
55
+ included."""
56
+ return crc16(b"".join(point for point in points if len(point) >= 3 and point[2] & 0x01))
57
+
58
+
59
+ @dataclass(frozen=True)
60
+ class ConfigurationChecksums:
61
+ """The checksums a device reports; None when not supported or not
62
+ readable."""
63
+ supported_attributes: Optional[int] = None
64
+ configured_scenes: Optional[int] = None
65
+ schedule1: Optional[int] = None
66
+ schedule2: Optional[int] = None
67
+
68
+
69
+ @dataclass
70
+ class ConfigurationCache:
71
+ """
72
+ Data of one device kept between get_full_poll_batch(cache=...) calls
73
+ so a poll reads the 2-byte checksums instead of the data they cover.
74
+
75
+ entries: {data attribute id: (checksum, response blocks)} for
76
+ ConfiguredScenes and the polled schedule, each with the checksum the
77
+ device reported in the same request. A poll whose checksum differs
78
+ (or can't be read) reads the data again. The device's own value is
79
+ the key, so no local computation is involved, and a change by any
80
+ client, this one included, shows up as a changed checksum.
81
+ supported_attributes_crc: SupportedAttributesCRC from the last poll.
82
+ When it changes (a firmware update), the poll result says so
83
+ (FullPollResult.attribute_table_changed) and the static attributes
84
+ are read again on the next poll. `entries` stay valid: each is still
85
+ checked against its own checksum.
86
+ static / static_read_at / static_refresh_interval: attributes that
87
+ rarely change (identity, firmware and hardware, color channels,
88
+ calibration, advanced settings, pump flow range and battery settings;
89
+ STATIC_POLL_ATTRIBUTES in device.py), read at most every
90
+ `static_refresh_interval` seconds and kept here in between. None (the
91
+ default) reads them on every poll. A client that writes one of them
92
+ calls invalidate_static() so the next poll reads them again.
93
+
94
+ One per device.
95
+ """
96
+ entries: dict[int, tuple[int, list[AttributeValue]]] = field(default_factory=dict)
97
+ supported_attributes_crc: Optional[int] = None
98
+ static: dict[int, list[AttributeValue]] = field(default_factory=dict)
99
+ static_read_at: Optional[float] = None
100
+ static_refresh_interval: Optional[float] = None
101
+
102
+ def static_due(self, now: float) -> bool:
103
+ """Whether the static attributes are to be read by a poll at
104
+ `now` (time.monotonic())."""
105
+ return (
106
+ self.static_refresh_interval is None or self.static_read_at is None
107
+ or now - self.static_read_at >= self.static_refresh_interval
108
+ )
109
+
110
+ def invalidate_static(self) -> None:
111
+ """Makes the next poll read the static attributes."""
112
+ self.static.clear()
113
+ self.static_read_at = None
114
+
115
+ def clear(self) -> None:
116
+ """Forgets everything: the next poll reads all data."""
117
+ self.entries.clear()
118
+ self.supported_attributes_crc = None
119
+ self.invalidate_static()