python-mobius 0.1.1__tar.gz → 0.1.3__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 (54) hide show
  1. python_mobius-0.1.3/CHANGELOG.md +233 -0
  2. {python_mobius-0.1.1 → python_mobius-0.1.3}/PKG-INFO +2 -1
  3. {python_mobius-0.1.1 → python_mobius-0.1.3}/documentation/00-overview.md +2 -0
  4. {python_mobius-0.1.1 → python_mobius-0.1.3}/documentation/03-attributes-and-opcodes.md +1 -1
  5. python_mobius-0.1.3/documentation/06-light-schedule.md +216 -0
  6. python_mobius-0.1.3/documentation/11-device-status-attributes.md +166 -0
  7. python_mobius-0.1.3/documentation/12-device-identity-and-address-stability.md +114 -0
  8. {python_mobius-0.1.1 → python_mobius-0.1.3}/pyproject.toml +3 -2
  9. {python_mobius-0.1.1 → python_mobius-0.1.3}/src/mobius/__init__.py +27 -5
  10. python_mobius-0.1.3/src/mobius/cli.py +125 -0
  11. {python_mobius-0.1.1 → python_mobius-0.1.3}/src/mobius/constants.py +103 -0
  12. python_mobius-0.1.3/src/mobius/device.py +1080 -0
  13. python_mobius-0.1.3/src/mobius/device_status.py +89 -0
  14. {python_mobius-0.1.1 → python_mobius-0.1.3}/src/mobius/discovery.py +53 -0
  15. python_mobius-0.1.3/src/mobius/modifiers.py +167 -0
  16. python_mobius-0.1.3/src/mobius/power.py +90 -0
  17. python_mobius-0.1.3/src/mobius/pump_status.py +50 -0
  18. python_mobius-0.1.3/tests/test_connection_cleanup.py +107 -0
  19. python_mobius-0.1.3/tests/test_device_status.py +167 -0
  20. python_mobius-0.1.3/tests/test_device_summary.py +158 -0
  21. python_mobius-0.1.3/tests/test_firmware_versions.py +141 -0
  22. python_mobius-0.1.3/tests/test_hyperdrive_device.py +172 -0
  23. python_mobius-0.1.3/tests/test_insolation_detection.py +133 -0
  24. python_mobius-0.1.3/tests/test_light_intensity_branching.py +139 -0
  25. python_mobius-0.1.3/tests/test_modifiers.py +156 -0
  26. python_mobius-0.1.3/tests/test_power.py +101 -0
  27. python_mobius-0.1.3/tests/test_pump_status.py +161 -0
  28. python_mobius-0.1.3/tests/test_schedule_intensity_scalar.py +111 -0
  29. python_mobius-0.1.3/tests/test_serial_identity.py +214 -0
  30. python_mobius-0.1.1/CHANGELOG.md +0 -46
  31. python_mobius-0.1.1/documentation/06-light-schedule.md +0 -90
  32. python_mobius-0.1.1/src/mobius/cli.py +0 -68
  33. python_mobius-0.1.1/src/mobius/device.py +0 -449
  34. {python_mobius-0.1.1 → python_mobius-0.1.3}/.gitignore +0 -0
  35. {python_mobius-0.1.1 → python_mobius-0.1.3}/LICENSE +0 -0
  36. {python_mobius-0.1.1 → python_mobius-0.1.3}/README.md +0 -0
  37. {python_mobius-0.1.1 → python_mobius-0.1.3}/documentation/01-ble-transport.md +0 -0
  38. {python_mobius-0.1.1 → python_mobius-0.1.3}/documentation/02-framing-and-crc.md +0 -0
  39. {python_mobius-0.1.1 → python_mobius-0.1.3}/documentation/04-device-identity.md +0 -0
  40. {python_mobius-0.1.1 → python_mobius-0.1.3}/documentation/05-scenes.md +0 -0
  41. {python_mobius-0.1.1 → python_mobius-0.1.3}/documentation/07-pump-schedule.md +0 -0
  42. {python_mobius-0.1.1 → python_mobius-0.1.3}/documentation/08-manufacturer-data.md +0 -0
  43. {python_mobius-0.1.1 → python_mobius-0.1.3}/documentation/09-thread-coap-relay.md +0 -0
  44. {python_mobius-0.1.1 → python_mobius-0.1.3}/documentation/10-known-gaps-and-open-questions.md +0 -0
  45. {python_mobius-0.1.1 → python_mobius-0.1.3}/src/mobius/crc.py +0 -0
  46. {python_mobius-0.1.1 → python_mobius-0.1.3}/src/mobius/frame.py +0 -0
  47. {python_mobius-0.1.1 → python_mobius-0.1.3}/src/mobius/manufacturer.py +0 -0
  48. {python_mobius-0.1.1 → python_mobius-0.1.3}/src/mobius/schedule.py +0 -0
  49. {python_mobius-0.1.1 → python_mobius-0.1.3}/tests/test_frame.py +0 -0
  50. {python_mobius-0.1.1 → python_mobius-0.1.3}/tests/test_light_schedule.py +0 -0
  51. {python_mobius-0.1.1 → python_mobius-0.1.3}/tests/test_manufacturer.py +0 -0
  52. {python_mobius-0.1.1 → python_mobius-0.1.3}/tests/test_manufacturer_lookup.py +0 -0
  53. {python_mobius-0.1.1 → python_mobius-0.1.3}/tests/test_pump_schedule.py +0 -0
  54. {python_mobius-0.1.1 → python_mobius-0.1.3}/tests/test_serial_decoding.py +0 -0
@@ -0,0 +1,233 @@
1
+ # Changelog
2
+
3
+ ## 0.1.3
4
+
5
+ - **Added firmware/hardware version reading.** `get_firmware_versions()`
6
+ (`FirmwareVersion`, attribute 1) with **confirmed-against-real-hardware**
7
+ display labels for EcoTech Marine devices (a real VorTech pump showed
8
+ exactly Radio/Radio Bootloader/Product OS/Product Bootloader; a real
9
+ Radion light showed exactly Product OS/Product Bootloader/Radio
10
+ Firmware/Filesystem/Radio OS/Radio/WLAN -- confirming lights use a
11
+ QCA4020-based radio with WiFi+BLE, unlike the simpler pump radio).
12
+ Ported the exact label mapping from `FirmwareType.name(Model)` in the
13
+ decompile. Versions are dot-joined strings built directly from the raw
14
+ response bytes (confirmed via `FirmwareType.format()`). Also added
15
+ `get_hardware_info()` (`HardwareRevision`, attribute 2, same fetch
16
+ pattern) -- returns raw bytes, no confirmed display format for those
17
+ fields. `get_device_summary()` now includes `"firmware_versions"`
18
+ automatically. 6 new tests.
19
+
20
+ - **Fixed a real CLI bug**: passing both a scan-time `BLEDevice` and
21
+ `serial=` into `MobiusDevice` in the connect loop did NOT actually give
22
+ serial-based robustness -- a directly-provided `BLEDevice` is always
23
+ used on the first connect, and the CLI only calls `connect()` once per
24
+ device, so the serial fallback path never triggered. That scan-time
25
+ `BLEDevice` could be tens of seconds stale by the time the loop reaches
26
+ the last device in a long scan -- confirmed against real hardware as the
27
+ cause of `"device disappeared"` errors. Fixed: the CLI now passes
28
+ `serial=` alone (omitting `device`) whenever available, forcing a fresh
29
+ resolution immediately before each connect.
30
+
31
+ - **Major fix: device identity is now serial-number-based, not BLE-address-based.**
32
+ Real-world testing surfaced a device whose advertised MAC address changed
33
+ between runs. Investigating the decompiled app's own `Peripheral` class
34
+ confirmed the official app never relies on address stability at all --
35
+ `Peripheral.equals()`/`hashCode()` are based purely on serial number, and
36
+ `Peripheral.update()` explicitly replaces the stored BLE address whenever
37
+ a new scan matches an existing device by serial. This library previously
38
+ assumed address stability throughout.
39
+ - New `find_device_by_serial()` and `dedupe_by_serial()`
40
+ (`mobius.discovery`).
41
+ - `MobiusDevice(device=None, serial=None, ...)`: `serial` is a new,
42
+ fully backward-compatible optional parameter. A directly-provided
43
+ `BLEDevice` is still used for the fast first connect, but if `serial`
44
+ is also set, any *subsequent* `connect()` on the same instance
45
+ re-resolves the current address via serial rather than assuming the
46
+ original address/BLEDevice is still valid. Existing address-only
47
+ usage is completely unchanged.
48
+ - `mobius-scan` CLI: applies `dedupe_by_serial()` automatically, passes
49
+ `serial=` when connecting, and has a new `--by-serial SERIAL` flag for
50
+ testing this directly against real hardware.
51
+ - 12 new tests. See
52
+ `documentation/12-device-identity-and-address-stability.md` for the
53
+ full evidence and rationale.
54
+ - Not yet applied to `ha-mobius`, which has the same address-based
55
+ assumption -- planned as a follow-up once this is confirmed working
56
+ against real hardware.
57
+
58
+ - **Fixed a real bug**: `get_device_summary()` -- the "read everything"
59
+ entrypoint the CLI and other callers actually use -- was never updated
60
+ to include `get_pump_flow_range()`/`get_pump_override_mode()`/
61
+ `get_battery_backup_info()`/`get_boosted_battery_info()`/
62
+ `get_group_info()`/`get_calibration_info()`/`get_maintenance_info()`
63
+ after they were added. All 7 methods worked correctly in isolation with
64
+ their own passing tests, but nothing exercised `get_device_summary()`
65
+ itself, so the gap went undetected through 103 passing tests. Caught via
66
+ real CLI usage. Fixed, and `tests/test_device_summary.py` now exists
67
+ specifically so this class of gap can't go undetected again (confirmed:
68
+ reverting the fix makes the new test fail with a clear message pointing
69
+ at the missing key).
70
+
71
+ - **Added group/sync, calibration, and maintenance reads.** New
72
+ `mobius.device_status` module. `get_group_info()` (`GroupMaster`/
73
+ `IsGroupMaster`, 900/911 -- confirmed dead in the app itself, zero real
74
+ usage beyond the enum definitions). `get_calibration_info()`
75
+ (`IsCalibrated`/`LastCalibrationTime`/`MinCalibratedSpeed`/
76
+ `MaxCalibratedSpeed`, 1300/1304/1308/1309) -- **important correction**:
77
+ despite "Speed"-sounding names, confirmed via the app's own UI gating
78
+ (`DeviceSettingsFragment.java`: `device.primitive.category() ==
79
+ M.DeviceCategory.Lighting`) that this is a light feature, not a pump
80
+ one. A separate, unrelated pump-side calibration flow exists in the app
81
+ (Nero-specific zero-flow/max-flow calibration) but isn't implemented and
82
+ doesn't apply to VorTech anyway (no physical flow sensor to calibrate).
83
+ `get_maintenance_info()` (`RecommendedMaintenanceInterval`/
84
+ `MaintenanceTimer`/`LastMaintenanceTime`/`MaintenanceDue`,
85
+ 1400-1403 -- also confirmed dead in the app; returns raw bytes rather
86
+ than guessing a numeric width with zero supporting evidence). 7 new
87
+ tests. Renamed `documentation/11-pump-status-and-capabilities.md` to
88
+ `documentation/11-device-status-attributes.md` to reflect the broader
89
+ (not pump-only) scope.
90
+
91
+ - **Added pump status/capability reads**: `get_pump_flow_range()`
92
+ (`MinimumGallonsPerHour`/`MaximumGallonsPerHour`, 707/708 -- confirmed
93
+ NOT to apply to VectraV1 pumps), `get_pump_override_mode()` (new
94
+ `PumpOverrideMode` enum, attribute 705 -- confidence note: confirmed
95
+ only as a *write* target in the decompile, never read; this library's
96
+ read path is a reasonable inference, not directly observed),
97
+ `get_battery_backup_info()` (`BatteryBackupSpeed`/`BatteryBackupMaxSpeed`,
98
+ 706/701), `get_boosted_battery_info()` (`BoostedBatteryPower`/`OnTime`/
99
+ `OffTime`, 803/804/805). New `mobius.pump_status` module
100
+ (`PumpFlowRange`, `BatteryBackupInfo`, `BoostedBatteryInfo`). All fail
101
+ soft (return `None`) for unsupported/model-dependent attributes rather
102
+ than raising. 9 new tests. See
103
+ `documentation/11-device-status-attributes.md`.
104
+
105
+ - **Added full hyperdrive support** (correct percentage conversion for
106
+ light channels boosted above their normal 100% rating). New
107
+ `mobius.power` module (`ChannelPowerInfo`, `channel_percent_value()`,
108
+ ported from `LightPowerInfo.java`) and `get_channel_power_info()`
109
+ (`MaxPower`/`NormalPower`, attributes 1504/1513). New
110
+ `get_current_light_percentages()` convenience method returns corrected
111
+ 0-100+ percentages instead of raw permille, flagging boosted channels via
112
+ `.diagnostics["hyperdrive_channels"]`. Confirmed byte-identical to the
113
+ old `raw/10` shortcut for the common (non-boosted) case; only changes
114
+ results for genuinely boosted channels. `LightPowerInfo`'s Prime/Hydra-
115
+ specific `gm8b4()` wattage override table is not replicated -- see
116
+ `documentation/06-light-schedule.md`.
117
+ - Also refactored `get_attribute()` into a thin wrapper around a new
118
+ `get_attribute_raw()`, which preserves the device-reported response
119
+ index -- required for `MaxPower`/`NormalPower`, where the index *is*
120
+ the `VisualID` byte value, not just a positional offset. Added a
121
+ regression test proving this distinction matters (a device that
122
+ starts its response at a non-zero index would have silently
123
+ mismatched values to the wrong channels under the old approach).
124
+ - 15 new tests across `tests/test_power.py` and
125
+ `tests/test_hyperdrive_device.py`.
126
+
127
+ - **Added Insolation detection/flagging.** Insolation itself is not
128
+ implemented (it's more than a missing modifier -- when active, the app
129
+ substitutes an entirely different, dynamically-generated schedule
130
+ before interpolating, not something layered on top the way lunar/
131
+ acclimation are). `get_current_light_intensities()` now returns a
132
+ `LightIntensityResult` -- a fully backward-compatible dict subclass with
133
+ an added `.diagnostics["insolation_active"]` flag, via a new
134
+ `get_insolation_enabled()` (`InsolationEnabled`, attribute 912).
135
+
136
+ - **Fixed a real accuracy bug**: `get_current_light_intensities()`
137
+ previously returned only the raw per-channel interpolated curve, missing
138
+ a schedule-level master intensity scalar (`Schedule1Intensity`, attribute
139
+ 511) that the real app applies on top. Caught by direct comparison
140
+ against real app screenshots showing different numbers than this
141
+ library computed for the same light at the same time. Traced to
142
+ `WidgetViewCell.java`/`IntensityView.java` in the decompiled source.
143
+ Added `get_schedule_intensity()`; `get_current_light_intensities()` now
144
+ applies it automatically.
145
+ - **Added lunar-phase and acclimation-ramp support**, the two remaining
146
+ modifiers flagged as gaps in the previous entry. New `mobius.modifiers`
147
+ module, ported from `LunarInfo.java`/`AcclimationInfo.java`:
148
+ - `lunar_percent_reduction(date)` -- verified against real,
149
+ independently-confirmed 2026 moon phase dates (full moon Jan 3, new
150
+ moon Jan 18); the ported algorithm matches exactly.
151
+ - `AcclimationInfo` -- linear intensity ramp from a starting percentage
152
+ up to full over a configured period.
153
+ - `is_night_segment()` -- ported from the boolean half of
154
+ `PointSchedule.getIntensitiesAtTime()`; confirmed this is specifically
155
+ "the dusk-to-night transition segment," not simply "is it night."
156
+ - `get_lunar_enabled()`, `get_acclimation_info()`,
157
+ `get_current_light_intensities()` now applies the full branching the
158
+ app itself uses (night+lunar / night-no-lunar / day+active-acclimation
159
+ / day-no-acclimation).
160
+ - "Insolation" (a real per-calendar-day natural daylight simulation,
161
+ likely a paid/premium feature -- see `mobius.modifiers` docstring for
162
+ details) is a separate, rarer modifier the app also layers in; still
163
+ not implemented -- see `documentation/06-light-schedule.md`.
164
+ - 19 new tests across `tests/test_modifiers.py` and
165
+ `tests/test_light_intensity_branching.py`.
166
+
167
+ ## 0.1.2
168
+
169
+ - Added `manufacturer_for_model()` / `MODEL_MANUFACTURER` (ported from
170
+ `M.Model.getManufacturer()`) — surfaces "EcoTech Marine" /
171
+ "AquaIllumination" / "Neptune Systems" / "NYOS" per device model. Now
172
+ included automatically in `get_device_info()`'s `"manufacturer"` field.
173
+ Added while building a Home Assistant integration on top of this library,
174
+ which needs a manufacturer string for its device registry.
175
+ - `get_device_info()`'s `"serial"` field is now decoded as ASCII text
176
+ instead of hex-encoded raw bytes (e.g. `"7V4Z00F143RBED"` instead of
177
+ `"3756345a30304631343352424544"`). Confirmed printable ASCII on every
178
+ device tested, via cross-validation against the same serial parsed from
179
+ BLE advertisement manufacturer data. Falls back to hex for any
180
+ non-printable-ASCII edge case not yet observed. Found while building the
181
+ Home Assistant integration on top of this library -- the hex-encoded
182
+ serial was producing unnecessarily long/ugly entity IDs.
183
+ - `get_pump_telemetry()` now includes a confirmed `"speed_percent"` field
184
+ alongside the existing raw `"speed"`. `MotorSpeed` is confirmed (via the
185
+ decompiled app's own display code) to be a percentage of max pump power
186
+ in tenths of a percent, NOT RPM -- `String.format("%.0f%%",
187
+ Math.abs(currentSpeed / 10.0f))` is literally what the app does to this
188
+ exact value. `speed_percent` is that same `abs(speed)/10` conversion;
189
+ `speed` is still returned raw since its sign encodes rotation direction
190
+ (reverse), which `speed_percent` discards.
191
+ - Fixed a real connection leak in `MobiusDevice.connect()`: if
192
+ `start_notify()` failed after the BLE connection was already
193
+ established (a known common flaky point right after a fresh connection),
194
+ the exception propagated out of `connect()` (`__aenter__`) -- and per the
195
+ async context manager protocol, a raising `__aenter__` means `__aexit__`
196
+ (and therefore `disconnect()`) is never called by the caller's `async
197
+ with` block. The already-open connection was left orphaned on the
198
+ adapter. Since callers like Home Assistant coordinators retry
199
+ periodically, this leak compounded over repeated failures, plausibly
200
+ exhausting a real adapter's connection-slot pool over time even with no
201
+ other integration competing for it and without any range/signal issue.
202
+ `connect()` now cleans up (disconnects) before re-raising if anything
203
+ fails after the connection is established. Added regression tests
204
+ confirming both the cleanup-on-failure and no-spurious-disconnect-on-
205
+ success behavior.
206
+
207
+ ## 0.1.0 — initial release
208
+
209
+ - Core FSCI/C2CI wire protocol: framing, CRC16, Get/Set-attribute codec.
210
+ Verified byte-for-byte against real captured packets.
211
+ - Scene control (`start_scene`, `start_feed_mode`, `resume_schedule`).
212
+ - Pump telemetry (speed, estimated GPH) — verified live against real
213
+ VorTech MP40QD pumps.
214
+ - Pump schedule reading (mode + parameters per time block) — verified live.
215
+ - Light schedule reading + client-side interpolation, matching the app's
216
+ own behavior — verified live against real Radion XR15 G6 Pro lights.
217
+ - Device discovery, identity (`get_device_info`), and a unified
218
+ `get_device_summary()` with explicit support tiers
219
+ (light / pump / pump-experimental / unsupported).
220
+ - Advertisement-only discovery: model, serial, and `pan_id` (tank/mesh
221
+ grouping) with no GATT connection required — verified against 4 real
222
+ devices sharing one `pan_id`.
223
+ - `mobius-scan` CLI.
224
+
225
+ ### Known gaps
226
+
227
+ - `DoseV1` and `HotSauceV1` primitive types have no parser (see
228
+ `documentation/10-known-gaps-and-open-questions.md`).
229
+ - `CoffeeV1` (NYOS Quantum) pump-primitive support is structurally
230
+ plausible but unverified against real hardware.
231
+ - Thread/CoAP device-to-device relay (used by the official app to reach
232
+ devices without a direct BLE connection) is not implemented — this
233
+ library always connects directly to each device instead.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mobius
3
- Version: 0.1.1
3
+ Version: 0.1.3
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
@@ -24,6 +24,7 @@ Requires-Python: >=3.9
24
24
  Requires-Dist: bleak>=0.21
25
25
  Provides-Extra: dev
26
26
  Requires-Dist: bleak-retry-connector>=3.0; extra == 'dev'
27
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
27
28
  Requires-Dist: pytest>=7.0; extra == 'dev'
28
29
  Provides-Extra: retry
29
30
  Requires-Dist: bleak-retry-connector>=3.0; extra == 'retry'
@@ -51,6 +51,8 @@ Interface conventions, extended by EcoTech).
51
51
  | [08-manufacturer-data.md](./08-manufacturer-data.md) | BLE advertisement format (no-connection discovery) |
52
52
  | [09-thread-coap-relay.md](./09-thread-coap-relay.md) | How the official app relays to devices without direct BLE (not implemented here) |
53
53
  | [10-known-gaps-and-open-questions.md](./10-known-gaps-and-open-questions.md) | Everything not yet confirmed or implemented |
54
+ | [11-device-status-attributes.md](./11-device-status-attributes.md) | Flow range, override mode, battery backup, group/sync, calibration (light-only), maintenance |
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 |
54
56
 
55
57
  ## On the use of AI assistance
56
58
 
@@ -87,7 +87,7 @@ against real pumps and lights returned correctly-structured, sane data.
87
87
  | `ErrorState` | 107 | See [04-device-identity.md](./04-device-identity.md) |
88
88
  | `CurrentScene` | 401 | Set to `sceneId(u16 LE) + durationMinutes(u16 LE)` to start a scene |
89
89
  | `Schedule1` / `Schedule2` | 500 / 503 | The programmed point-schedule — see 06/07 |
90
- | `MotorSpeed` | 700 | Live pump speed (int16) |
90
+ | `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 code, `String.format("%.0f%%", Math.abs(currentSpeed / 10.0f))`. |
91
91
  | `SupportedColorChannels` | 901 | Static list of a light's channels |
92
92
 
93
93
  See `src/mobius/constants.py` for the complete implemented list.
@@ -0,0 +1,216 @@
1
+ # Light schedule
2
+
3
+ ## There is no "current intensity" attribute
4
+
5
+ This is the single most important, least obvious thing about lights in
6
+ this protocol: **the app does not read live output from a light at all.**
7
+ There's no attribute you can `Get` that tells you what a light is currently
8
+ displaying. Instead, the app fetches the entire programmed point-schedule
9
+ and computes the current intensity **client-side**, by linearly
10
+ interpolating between the two points that bracket the current time, then
11
+ applying a schedule-level master intensity scalar on top (see below), plus
12
+ acclimation-ramp and lunar-cycle modifiers this library still doesn't
13
+ implement.
14
+
15
+ This library's `get_current_light_intensities()` replicates that
16
+ client-side computation. **Correction to an earlier version of this doc**:
17
+ this was previously described as "confirmed correct by validating against
18
+ real fetched schedules" -- that validation only checked the per-channel
19
+ interpolation curve, not the schedule-level scalar below, which was
20
+ missing entirely at the time and silently returned values that didn't
21
+ match the app's real displayed numbers. Caught by direct comparison
22
+ against real app screenshots. Fixed; see below.
23
+
24
+ ## Schedule-level master intensity (`Schedule1Intensity`, attribute 511)
25
+
26
+ Confirmed via `WidgetViewCell.java`/`IntensityView.java` in the decompiled
27
+ app: on top of the per-channel interpolated curve, the app applies a
28
+ **separate schedule-level master dimmer** -- `dCurrentIntensity =
29
+ schedule.intensity / 1000.0`, multiplied onto every channel's interpolated
30
+ value. This is genuinely separate from any individual channel's own value,
31
+ including the "Brightness" `VisualID` channel (which is just another
32
+ regular per-channel value some models expose, not this).
33
+
34
+ `get_schedule_intensity()` fetches this (a plain `Get` on
35
+ `Schedule1Intensity`/`Schedule2Intensity`, no special indexing -- a short,
36
+ 0-1000, default 500/50% per the app's own initialization code).
37
+ `get_current_light_intensities()` now applies it automatically.
38
+
39
+ **Update**: lunar-phase and acclimation-ramp reductions, which are layered
40
+ on top of this same scalar under specific conditions (night + lunar phases
41
+ enabled; an active, incomplete acclimation period), are now also
42
+ implemented -- see "Lunar phase and acclimation ramp" below. "Insolation"
43
+ (a separate, rarer modifier) is still not.
44
+
45
+ ## Wire format
46
+
47
+ ### `Schedule1` / `Schedule2` attribute (500 / 503)
48
+
49
+ `Get`-ing this attribute with `index=0, count=0xFFFF` returns every
50
+ programmed point as a separate element in the response (see
51
+ [03-attributes-and-opcodes.md](./03-attributes-and-opcodes.md) for the
52
+ general Get-response shape).
53
+
54
+ ### Point format
55
+
56
+ ```
57
+ time (u16 LE, minutes since midnight, 0-1439)
58
+ flags (u8)
59
+ primitiveData (variable)
60
+ ```
61
+
62
+ Flags (confirmed bitmasks from the decompile):
63
+
64
+ | Bit value | Meaning |
65
+ |---|---|
66
+ | 1 | `ACTIVE` — points without this bit set are skipped by this library entirely |
67
+ | 2 | `NIGHT` |
68
+ | 6 | `SUNRISE` (combination flag) |
69
+ | 10 | `SUNSET` (combination flag) |
70
+
71
+ ### `LightPrimitive` (the `primitiveData` for `VisualV1` points)
72
+
73
+ Repeating triplets, one per channel the point defines:
74
+
75
+ ```
76
+ VisualID (u8) + intensity (u16 LE, 0-1000 = 0.0-100.0%)
77
+ ```
78
+
79
+ Confirmed via a full 9-point schedule fetched live from two real XR15
80
+ lights (identical schedules on both — a deliberately symmetric setup, not a
81
+ parsing artifact, confirmed by the two lights having different serial
82
+ numbers).
83
+
84
+ ## Interpolation algorithm
85
+
86
+ Ported from `PointSchedule.getIntensitiesAtTime()`:
87
+
88
+ 1. Sort points by time.
89
+ 2. Find the two points that bracket the target minute-of-day, wrapping
90
+ around midnight (the point after the last one is treated as being on
91
+ "the next day," i.e. its time + 1440).
92
+ 3. For each channel present in either bracketing point, linearly
93
+ interpolate: `value(t) = v1 + (v2 - v1) * (t - t1) / (t2 - t1)`.
94
+ 4. Channels absent from a point default to `0` (off).
95
+
96
+ Verified against a synthetic two-point schedule (exact endpoint values,
97
+ midpoint blending, and midnight-wraparound blending all behave correctly —
98
+ see `tests/test_light_schedule.py`) and against a real 9-point schedule
99
+ fetched live, which produced a coherent daily light-intensity curve
100
+ (dim moonlight overnight → ramp to full blues/UV midday → wind back down).
101
+
102
+ ## `SupportedColorChannels` (attribute 901)
103
+
104
+ A static list of `VisualID` values (channel identities) a given light has —
105
+ this is capability metadata, not live state. Confirmed via `Get` with
106
+ `index=0, count=0xFFFF` against real hardware; both test lights reported
107
+ the same 13-channel set (`Violet, CoolWhite, WarmWhite, Blue, UV, RoyalBlue,
108
+ Green, Red, MoonlightBlue, MoonlightWhite, Brightness, StormProbability,
109
+ CloudProbability`).
110
+
111
+ ## Lunar phase and acclimation ramp
112
+
113
+ Both now implemented, on top of the schedule-level master intensity above.
114
+ Ported from `LunarInfo.java`/`AcclimationInfo.java` in the decompiled app.
115
+
116
+ **Lunar phase** (`lunar_percent_reduction(date)`): a simplified moon-phase
117
+ model -- a Julian-Day-based calculation (`julian_day_from_date()`, the
118
+ standard Fliegel-Van Flandern conversion) feeding a 16-entry lookup table
119
+ (`LUNAR_CYCLE_SCALERS`), returning `0.0` at new moon ramping to `1.0` at
120
+ full moon. Verified against real, independently-confirmed 2026 moon phase
121
+ dates (full moon Jan 3 2026, new moon Jan 18 2026) -- the ported algorithm
122
+ matches exactly.
123
+
124
+ This replaces the normal schedule-intensity scalar specifically during the
125
+ **dusk-to-night segment** of the schedule (see `is_night_segment()` below),
126
+ and only when `LunarPhasesEnabled` (attribute 907) is set on the device --
127
+ otherwise that segment just uses a flat `1.0` (full/unreduced) scalar,
128
+ matching the app's own fallback.
129
+
130
+ **`is_night_segment()`**: ported from the boolean half of
131
+ `PointSchedule.getIntensitiesAtTime()`'s return value. This is *not*
132
+ simply "is it night" -- confirmed by reading the Java source directly, it's
133
+ specifically true for the segment whose start point is NOT flagged `NIGHT`
134
+ or `SUNRISE` and whose end point IS flagged `NIGHT` (the dusk-transition
135
+ segment), or when the query time exactly matches a `NIGHT`-flagged point.
136
+
137
+ **Acclimation ramp** (`AcclimationInfo`): a linear intensity ramp from
138
+ `start_intensity` (0-1000 permille) up to full (1000) over `period_days`,
139
+ starting at `start_time` (unix timestamp). Applied as an additional
140
+ multiplier on the schedule-level intensity, only during normal (non-night)
141
+ segments, only while `AcclimationEnabled` is set and the ramp period hasn't
142
+ completed yet.
143
+
144
+ `get_current_light_intensities()` fetches and applies all of this
145
+ automatically -- no separate calls needed for the common case.
146
+
147
+ ## Insolation detection (flagged, not implemented)
148
+
149
+ Insolation itself is not implemented (see below), but
150
+ `get_current_light_intensities()` does cheaply detect whether it's active
151
+ and flag it, via `LightIntensityResult` -- the return value is a real
152
+ dict (fully backward compatible with code that indexes/iterates it like a
153
+ plain dict), with an added `.diagnostics` attribute:
154
+
155
+ ```python
156
+ result = await device.get_current_light_intensities()
157
+ if result.diagnostics["insolation_active"]:
158
+ # values were computed from the wrong schedule entirely -- see below
159
+ ...
160
+ ```
161
+
162
+ This is a best-effort warning, not a refusal: the values are still
163
+ returned even when Insolation is detected as active, since a possibly-
164
+ wrong-but-present value is often more useful than nothing, and the
165
+ severity depends on how different the real Insolation-derived schedule is
166
+ from the manually-programmed one on a given day.
167
+
168
+ ## Hyperdrive (boosted channels above 100%)
169
+
170
+ Some light channels can be driven above their normally-rated power
171
+ (`MaxPower` > `NormalPower` for that channel), allowing programmed
172
+ intensity values above `1000` permille (100%) on the wire, up to `2000`
173
+ (200% of normal). Naively dividing a boosted raw value by 10 to get a
174
+ percentage is wrong once it's above 1000 -- the true percentage depends on
175
+ how much headroom that specific channel actually has.
176
+
177
+ `get_current_light_intensities()` (and `interpolate_light_schedule()`
178
+ underneath it) already handled boosted raw values correctly all along --
179
+ nothing was capped or clamped, so the interpolation itself was never
180
+ wrong. What was missing was converting a boosted raw value to an accurate
181
+ *percentage* for display.
182
+
183
+ `get_current_light_percentages()` is the corrected alternative: it fetches
184
+ `MaxPower`/`NormalPower` (`get_channel_power_info()`, attributes `1504`/
185
+ `1513`) and applies `channel_percent_value()` (`mobius.power`, ported from
186
+ `LightPowerInfo.getPercentValue()`) per channel. For the overwhelming
187
+ common case -- non-hyperdrive channels/devices, i.e. any raw value at or
188
+ below 1000 -- this gives byte-identical results to the old `raw/10`
189
+ shortcut; the extra machinery only changes anything for genuinely boosted
190
+ channels. It also flags which channels are currently boosted via
191
+ `.diagnostics["hyperdrive_channels"]`.
192
+
193
+ This costs extra BLE round trips beyond `get_current_light_intensities()`
194
+ alone (two more `Get`s for `MaxPower`/`NormalPower`). Use
195
+ `get_current_light_intensities()` directly if you don't need hyperdrive
196
+ accuracy or want to minimize round trips.
197
+
198
+ **Not replicated**: `LightPowerInfo`'s `gm8b4()` lookup table -- a
199
+ hardcoded wattage override for a handful of specific AquaIllumination
200
+ Prime/Hydra model + channel combinations. Only relevant to those specific
201
+ models; the general case (including every EcoTech Radion model this
202
+ library has been verified against) doesn't use it.
203
+
204
+ ## Not implemented here
205
+
206
+ - "Insolation" -- a real per-calendar-day natural daylight simulation:
207
+ each light stores a 365-entry table (one per day of the year, packed
208
+ into 8 bytes via `InsolationTable1`/`InsolationTable2`), where every
209
+ entry holds sunrise time, sunset time, sun intensity (0-100%), moonrise
210
+ time, moonset time, and moon intensity (0-100%) for that specific
211
+ calendar day. Lets a light automatically track real seasonal daylight
212
+ variation instead of running one fixed schedule year-round. Gated
213
+ behind `AccountSettings.hasPermission()` in the app -- likely a
214
+ paid/premium feature, not merely an obscure one. Not implemented here;
215
+ treated as always-disabled.
216
+ - Writing/programming a schedule (only reading is implemented).
@@ -0,0 +1,166 @@
1
+ # Device status attributes (pump-specific and generic)
2
+
3
+ Beyond live telemetry (`get_pump_telemetry()`, see
4
+ [03-attributes-and-opcodes.md](./03-attributes-and-opcodes.md)) and the
5
+ programmed schedule ([07-pump-schedule.md](./07-pump-schedule.md)), pumps
6
+ expose several additional static-capability and configuration attributes.
7
+ This document also covers a few attributes that aren't pump-specific
8
+ (group/sync info, calibration, maintenance tracking), grouped here since
9
+ they're all read via the same small set of `MobiusDevice` methods.
10
+
11
+ ## Pump-specific attributes
12
+
13
+ ## Flow range (`get_pump_flow_range()`)
14
+
15
+ `MinimumGallonsPerHour`/`MaximumGallonsPerHour` (707/708) — the pump's
16
+ rated flow spec range. This is static capability info (like
17
+ `SupportedColorChannels` for lights), not a live reading. Confirmed via
18
+ `FlowRange.java` in the decompile to **not** apply to `VectraV1` pumps
19
+ specifically (explicitly excluded in the app's own support check).
20
+
21
+ ## Override mode (`get_pump_override_mode()`)
22
+
23
+ `PumpOverrideMode` (705) — `None`/`AllOff`/`Manual`. **Confidence note**:
24
+ confirmed in the decompile only as a *write* target (the app sets it to
25
+ `None` to exit override mode after the pump-side calibration flow) --
26
+ never actually read anywhere in the app itself. This library's read
27
+ implementation is a reasonable inference (plain byte enum, matching every
28
+ other attribute of this shape in the protocol), not something directly
29
+ observed being read by the real app.
30
+
31
+ ## Battery backup (`get_battery_backup_info()`, `get_boosted_battery_info()`)
32
+
33
+ - `BatteryBackupSpeed` (706, current setting) / `BatteryBackupMaxSpeed`
34
+ (701, ceiling) — confirmed via `BatterySpeed.java`. Same 0-1000-permille
35
+ speed-percentage convention as `MotorSpeed`.
36
+ - `BoostedBatteryPower` (803) / `BoostedBatteryPowerOnTime` (804) /
37
+ `BoostedBatteryPowerOffTime` (805) — confirmed via `BoostedBattery.java`.
38
+ A pulsed battery-backup power mode. Units for the on/off time fields
39
+ aren't confirmed beyond being raw shorts (plausibly seconds, matching
40
+ the pattern of other duration fields elsewhere in the protocol, but not
41
+ independently verified).
42
+
43
+ All four methods fail soft (return `None`) if the underlying attribute(s)
44
+ aren't supported or fail to fetch, rather than raising -- these are all
45
+ genuinely optional/model-dependent.
46
+
47
+ ## Generic (not pump-specific)
48
+
49
+ ## Group/sync info (`get_group_info()`)
50
+
51
+ `GroupMaster` (900) / `IsGroupMaster` (911). **Confidence note**: neither
52
+ attribute is referenced anywhere in the decompiled app beyond their own
53
+ enum definitions -- genuinely dead/unused in this app version, the same
54
+ situation as `MotorRPM`. `group_master` is returned as raw bytes rather
55
+ than interpreted. **Confirmed against real hardware to be 28 bytes when
56
+ populated** (a real Radion light returned a 28-byte all-zero value; real
57
+ VorTech pumps tested returned nothing for either attribute, i.e.
58
+ unsupported) -- this corrects an earlier guess here that it was probably
59
+ 8 bytes by loose analogy to the `Master` field in `Sync`/`EcoSmartBack`
60
+ pump modes (see [07-pump-schedule.md](./07-pump-schedule.md)); that
61
+ analogy was wrong. The all-zero value observed is consistent with "no
62
+ group master set," but the field's actual semantics remain unconfirmed --
63
+ still dead in the app itself. `is_group_master` uses the
64
+ single-byte-boolean convention confirmed consistently across every other
65
+ boolean attribute in this protocol.
66
+
67
+ ## Calibration (`get_calibration_info()`) -- a LIGHT feature, not a pump one
68
+
69
+ `IsCalibrated` (1300) / `LastCalibrationTime` (1304) / `MinCalibratedSpeed`
70
+ (1308) / `MaxCalibratedSpeed` (1309).
71
+
72
+ Despite the "Speed"-sounding attribute names (a naming legacy from these
73
+ attributes' shared use across device types in the protocol), **confirmed
74
+ via the decompiled app's own UI gating** that this is surfaced only for
75
+ lighting-category devices:
76
+
77
+ ```java
78
+ // DeviceSettingsFragment.java
79
+ if (Feature.checkSupport(Calibration.class, this.device)
80
+ && this.device.primitive.category() == M.DeviceCategory.Lighting) {
81
+ this.sects.add(4, Section.Calibration);
82
+ }
83
+ ```
84
+
85
+ Tapping it launches `LightCalibrationActivity`, a dedicated LED low-end/
86
+ high-end calibration wizard (`c2development.mobius.lighting.calibration.*`
87
+ in the decompile). There's a *separate*, unrelated pump-side calibration
88
+ flow in the app (`c2development.mobius.flow.calibration.CalibrationActivity`
89
+ -- zero-flow/max-flow calibration, relevant to Nero-class pumps with a
90
+ genuine flow sensor, gated elsewhere by checking the pump isn't in Vectra
91
+ closed-loop mode) -- **not implemented here**, and unrelated to this
92
+ attribute set. It wouldn't apply to VorTech pumps anyway, which use
93
+ magnetic drive with no physical flow sensor to calibrate.
94
+
95
+ Confirmed via real device testing that VorTech pumps specifically do not
96
+ expose Calibration in the app -- expect `get_calibration_info()` to
97
+ return `None` (unsupported) against VorTech and similar pumps, and to
98
+ work against Radion lights.
99
+
100
+ ## Maintenance tracking (`get_maintenance_info()`)
101
+
102
+ `RecommendedMaintenanceInterval` (1400) / `MaintenanceTimer` (1401) /
103
+ `LastMaintenanceTime` (1402) / `MaintenanceDue` (1403). **Confidence
104
+ note**: none of these four attributes are referenced anywhere in the
105
+ decompiled app beyond their own enum definitions -- genuinely dead/unused
106
+ in this app version, the same situation as `MotorRPM` and
107
+ `GroupMaster`/`IsGroupMaster`. Because there's zero evidence for the
108
+ actual byte width/encoding of any of these fields, this deliberately
109
+ returns **raw bytes** for each one rather than guessing a specific
110
+ integer width and risking silently misinterpreting real device data. If
111
+ you have hardware that actually populates these, please report back what
112
+ you observe so the real format can be pinned down.
113
+
114
+ ## Firmware and hardware info (`get_firmware_versions()`, `get_hardware_info()`)
115
+
116
+ `FirmwareVersion` (attribute 1) and `HardwareRevision` (attribute 2) are
117
+ both sub-indexed -- a `Get` with `index=0, count=0xFFFF` returns one
118
+ element per component the device has, the same "get all elements" pattern
119
+ as `MaxPower`/`NormalPower`/`SupportedColorChannels`. The sub-index is
120
+ `FirmwareType`/`HardwareInfo` respectively (see `mobius.constants`).
121
+
122
+ **Firmware version display labels are fully confirmed against real
123
+ hardware.** `FirmwareType.name(Model)` in the decompiled app maps each
124
+ `FirmwareType` value to the exact label shown in the UI, gated by
125
+ manufacturer. For EcoTech Marine devices specifically (the `isEtm()`
126
+ branch), confirmed to match real output exactly:
127
+
128
+ | `FirmwareType` | Value | Label |
129
+ |---|---|---|
130
+ | `OS` | 1 | Radio |
131
+ | `Bootloader` | 2 | Radio Bootloader |
132
+ | `MainMicroOS` | 3 | Product OS |
133
+ | `MainMicroBootloader` | 4 | Product Bootloader |
134
+ | `QCA4020Firmware` | 5 | Radio Firmware |
135
+ | `QCA4020FileSystem` | 6 | Filesystem |
136
+ | `QCA4020M4F` | 7 | Radio OS |
137
+ | `QCA4020M0` | 8 | Radio |
138
+ | `QCA4020WLAN` | 9 | WLAN |
139
+
140
+ A real VorTech pump showed exactly Radio/Radio Bootloader/Product
141
+ OS/Product Bootloader (values 1-4); a real Radion light showed exactly
142
+ Product OS/Product Bootloader/Radio Firmware/Filesystem/Radio OS/Radio/
143
+ WLAN (values 3-9, confirming lights use a QCA4020-based radio chip with
144
+ WiFi+BLE, unlike the simpler radio in pumps). `get_firmware_versions()`
145
+ takes an optional `model` parameter to apply these confirmed labels;
146
+ without it (or for non-EcoTech models, whose label mapping in the
147
+ decompile is a different, unverified branch), falls back to the raw
148
+ `FirmwareType` enum name.
149
+
150
+ Each version is a **dot-joined string built directly from the raw
151
+ response bytes** (e.g. `[4, 0, 21]` -> `"4.0.21"`) -- confirmed via
152
+ `FirmwareType.format()` in the decompile, which does exactly that and
153
+ nothing more exotic.
154
+
155
+ `get_hardware_info()` follows the same fetch pattern for
156
+ `HardwareRevision`/`HardwareInfo`, but **no display-formatting convention
157
+ is confirmed** for those fields (`Color`/`Revision`/`ProductType`/
158
+ `RadioType`/`MotorType`/`Segments` read more like small integer/enum
159
+ codes than version numbers) -- returns raw bytes per field rather than
160
+ guessing a format.
161
+
162
+ Both are included automatically in `get_device_summary()`'s
163
+ `"firmware_versions"` field (using the confirmed EcoTech labels, since
164
+ `get_device_summary()` already has the device's `Model` on hand).
165
+ `get_hardware_info()` is not currently wired into the summary -- call it
166
+ directly if you want it.