python-mobius 0.1.2__tar.gz → 0.1.4__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.4/CHANGELOG.md +246 -0
  2. {python_mobius-0.1.2 → python_mobius-0.1.4}/PKG-INFO +1 -1
  3. {python_mobius-0.1.2 → python_mobius-0.1.4}/documentation/00-overview.md +2 -0
  4. python_mobius-0.1.4/documentation/06-light-schedule.md +216 -0
  5. python_mobius-0.1.4/documentation/11-device-status-attributes.md +166 -0
  6. python_mobius-0.1.4/documentation/12-device-identity-and-address-stability.md +114 -0
  7. {python_mobius-0.1.2 → python_mobius-0.1.4}/pyproject.toml +1 -1
  8. {python_mobius-0.1.2 → python_mobius-0.1.4}/src/mobius/__init__.py +27 -5
  9. python_mobius-0.1.4/src/mobius/cli.py +125 -0
  10. {python_mobius-0.1.2 → python_mobius-0.1.4}/src/mobius/constants.py +103 -0
  11. python_mobius-0.1.4/src/mobius/device.py +1095 -0
  12. python_mobius-0.1.4/src/mobius/device_status.py +89 -0
  13. {python_mobius-0.1.2 → python_mobius-0.1.4}/src/mobius/discovery.py +53 -0
  14. python_mobius-0.1.4/src/mobius/modifiers.py +167 -0
  15. python_mobius-0.1.4/src/mobius/power.py +90 -0
  16. python_mobius-0.1.4/src/mobius/pump_status.py +50 -0
  17. {python_mobius-0.1.2 → python_mobius-0.1.4}/tests/test_connection_cleanup.py +14 -0
  18. python_mobius-0.1.4/tests/test_device_status.py +167 -0
  19. python_mobius-0.1.4/tests/test_device_summary.py +158 -0
  20. python_mobius-0.1.4/tests/test_firmware_versions.py +141 -0
  21. python_mobius-0.1.4/tests/test_hyperdrive_device.py +172 -0
  22. python_mobius-0.1.4/tests/test_insolation_detection.py +133 -0
  23. python_mobius-0.1.4/tests/test_light_intensity_branching.py +139 -0
  24. python_mobius-0.1.4/tests/test_modifiers.py +156 -0
  25. python_mobius-0.1.4/tests/test_power.py +101 -0
  26. python_mobius-0.1.4/tests/test_pump_status.py +161 -0
  27. python_mobius-0.1.4/tests/test_schedule_intensity_scalar.py +111 -0
  28. python_mobius-0.1.4/tests/test_serial_identity.py +214 -0
  29. python_mobius-0.1.2/CHANGELOG.md +0 -69
  30. python_mobius-0.1.2/documentation/06-light-schedule.md +0 -90
  31. python_mobius-0.1.2/src/mobius/cli.py +0 -68
  32. python_mobius-0.1.2/src/mobius/device.py +0 -483
  33. {python_mobius-0.1.2 → python_mobius-0.1.4}/.gitignore +0 -0
  34. {python_mobius-0.1.2 → python_mobius-0.1.4}/LICENSE +0 -0
  35. {python_mobius-0.1.2 → python_mobius-0.1.4}/README.md +0 -0
  36. {python_mobius-0.1.2 → python_mobius-0.1.4}/documentation/01-ble-transport.md +0 -0
  37. {python_mobius-0.1.2 → python_mobius-0.1.4}/documentation/02-framing-and-crc.md +0 -0
  38. {python_mobius-0.1.2 → python_mobius-0.1.4}/documentation/03-attributes-and-opcodes.md +0 -0
  39. {python_mobius-0.1.2 → python_mobius-0.1.4}/documentation/04-device-identity.md +0 -0
  40. {python_mobius-0.1.2 → python_mobius-0.1.4}/documentation/05-scenes.md +0 -0
  41. {python_mobius-0.1.2 → python_mobius-0.1.4}/documentation/07-pump-schedule.md +0 -0
  42. {python_mobius-0.1.2 → python_mobius-0.1.4}/documentation/08-manufacturer-data.md +0 -0
  43. {python_mobius-0.1.2 → python_mobius-0.1.4}/documentation/09-thread-coap-relay.md +0 -0
  44. {python_mobius-0.1.2 → python_mobius-0.1.4}/documentation/10-known-gaps-and-open-questions.md +0 -0
  45. {python_mobius-0.1.2 → python_mobius-0.1.4}/src/mobius/crc.py +0 -0
  46. {python_mobius-0.1.2 → python_mobius-0.1.4}/src/mobius/frame.py +0 -0
  47. {python_mobius-0.1.2 → python_mobius-0.1.4}/src/mobius/manufacturer.py +0 -0
  48. {python_mobius-0.1.2 → python_mobius-0.1.4}/src/mobius/schedule.py +0 -0
  49. {python_mobius-0.1.2 → python_mobius-0.1.4}/tests/test_frame.py +0 -0
  50. {python_mobius-0.1.2 → python_mobius-0.1.4}/tests/test_light_schedule.py +0 -0
  51. {python_mobius-0.1.2 → python_mobius-0.1.4}/tests/test_manufacturer.py +0 -0
  52. {python_mobius-0.1.2 → python_mobius-0.1.4}/tests/test_manufacturer_lookup.py +0 -0
  53. {python_mobius-0.1.2 → python_mobius-0.1.4}/tests/test_pump_schedule.py +0 -0
  54. {python_mobius-0.1.2 → python_mobius-0.1.4}/tests/test_serial_decoding.py +0 -0
@@ -0,0 +1,246 @@
1
+ # Changelog
2
+
3
+ ## 0.1.4
4
+
5
+ - Added `MobiusDevice.is_connected` property -- lets callers check
6
+ connection state before deciding whether to read directly or reconnect
7
+ first, enabling a persistent-connection usage pattern (connect() once,
8
+ read many times, only reconnect on an actual failure) instead of
9
+ connecting/disconnecting on every read. `connect()`/`disconnect()` were
10
+ already separate public methods (not just wrapped by `__aenter__`/
11
+ `__aexit__`), so this required no other changes -- just a convenience
12
+ property exposing what was previously only checked internally via
13
+ `self._client.is_connected`. Added for `ha-mobius`'s move to persistent
14
+ connections. 1 new test.
15
+
16
+ ## 0.1.3
17
+
18
+ - **Added firmware/hardware version reading.** `get_firmware_versions()`
19
+ (`FirmwareVersion`, attribute 1) with **confirmed-against-real-hardware**
20
+ display labels for EcoTech Marine devices (a real VorTech pump showed
21
+ exactly Radio/Radio Bootloader/Product OS/Product Bootloader; a real
22
+ Radion light showed exactly Product OS/Product Bootloader/Radio
23
+ Firmware/Filesystem/Radio OS/Radio/WLAN -- confirming lights use a
24
+ QCA4020-based radio with WiFi+BLE, unlike the simpler pump radio).
25
+ Ported the exact label mapping from `FirmwareType.name(Model)` in the
26
+ decompile. Versions are dot-joined strings built directly from the raw
27
+ response bytes (confirmed via `FirmwareType.format()`). Also added
28
+ `get_hardware_info()` (`HardwareRevision`, attribute 2, same fetch
29
+ pattern) -- returns raw bytes, no confirmed display format for those
30
+ fields. `get_device_summary()` now includes `"firmware_versions"`
31
+ automatically. 6 new tests.
32
+
33
+ - **Fixed a real CLI bug**: passing both a scan-time `BLEDevice` and
34
+ `serial=` into `MobiusDevice` in the connect loop did NOT actually give
35
+ serial-based robustness -- a directly-provided `BLEDevice` is always
36
+ used on the first connect, and the CLI only calls `connect()` once per
37
+ device, so the serial fallback path never triggered. That scan-time
38
+ `BLEDevice` could be tens of seconds stale by the time the loop reaches
39
+ the last device in a long scan -- confirmed against real hardware as the
40
+ cause of `"device disappeared"` errors. Fixed: the CLI now passes
41
+ `serial=` alone (omitting `device`) whenever available, forcing a fresh
42
+ resolution immediately before each connect.
43
+
44
+ - **Major fix: device identity is now serial-number-based, not BLE-address-based.**
45
+ Real-world testing surfaced a device whose advertised MAC address changed
46
+ between runs. Investigating the decompiled app's own `Peripheral` class
47
+ confirmed the official app never relies on address stability at all --
48
+ `Peripheral.equals()`/`hashCode()` are based purely on serial number, and
49
+ `Peripheral.update()` explicitly replaces the stored BLE address whenever
50
+ a new scan matches an existing device by serial. This library previously
51
+ assumed address stability throughout.
52
+ - New `find_device_by_serial()` and `dedupe_by_serial()`
53
+ (`mobius.discovery`).
54
+ - `MobiusDevice(device=None, serial=None, ...)`: `serial` is a new,
55
+ fully backward-compatible optional parameter. A directly-provided
56
+ `BLEDevice` is still used for the fast first connect, but if `serial`
57
+ is also set, any *subsequent* `connect()` on the same instance
58
+ re-resolves the current address via serial rather than assuming the
59
+ original address/BLEDevice is still valid. Existing address-only
60
+ usage is completely unchanged.
61
+ - `mobius-scan` CLI: applies `dedupe_by_serial()` automatically, passes
62
+ `serial=` when connecting, and has a new `--by-serial SERIAL` flag for
63
+ testing this directly against real hardware.
64
+ - 12 new tests. See
65
+ `documentation/12-device-identity-and-address-stability.md` for the
66
+ full evidence and rationale.
67
+ - Not yet applied to `ha-mobius`, which has the same address-based
68
+ assumption -- planned as a follow-up once this is confirmed working
69
+ against real hardware.
70
+
71
+ - **Fixed a real bug**: `get_device_summary()` -- the "read everything"
72
+ entrypoint the CLI and other callers actually use -- was never updated
73
+ to include `get_pump_flow_range()`/`get_pump_override_mode()`/
74
+ `get_battery_backup_info()`/`get_boosted_battery_info()`/
75
+ `get_group_info()`/`get_calibration_info()`/`get_maintenance_info()`
76
+ after they were added. All 7 methods worked correctly in isolation with
77
+ their own passing tests, but nothing exercised `get_device_summary()`
78
+ itself, so the gap went undetected through 103 passing tests. Caught via
79
+ real CLI usage. Fixed, and `tests/test_device_summary.py` now exists
80
+ specifically so this class of gap can't go undetected again (confirmed:
81
+ reverting the fix makes the new test fail with a clear message pointing
82
+ at the missing key).
83
+
84
+ - **Added group/sync, calibration, and maintenance reads.** New
85
+ `mobius.device_status` module. `get_group_info()` (`GroupMaster`/
86
+ `IsGroupMaster`, 900/911 -- confirmed dead in the app itself, zero real
87
+ usage beyond the enum definitions). `get_calibration_info()`
88
+ (`IsCalibrated`/`LastCalibrationTime`/`MinCalibratedSpeed`/
89
+ `MaxCalibratedSpeed`, 1300/1304/1308/1309) -- **important correction**:
90
+ despite "Speed"-sounding names, confirmed via the app's own UI gating
91
+ (`DeviceSettingsFragment.java`: `device.primitive.category() ==
92
+ M.DeviceCategory.Lighting`) that this is a light feature, not a pump
93
+ one. A separate, unrelated pump-side calibration flow exists in the app
94
+ (Nero-specific zero-flow/max-flow calibration) but isn't implemented and
95
+ doesn't apply to VorTech anyway (no physical flow sensor to calibrate).
96
+ `get_maintenance_info()` (`RecommendedMaintenanceInterval`/
97
+ `MaintenanceTimer`/`LastMaintenanceTime`/`MaintenanceDue`,
98
+ 1400-1403 -- also confirmed dead in the app; returns raw bytes rather
99
+ than guessing a numeric width with zero supporting evidence). 7 new
100
+ tests. Renamed `documentation/11-pump-status-and-capabilities.md` to
101
+ `documentation/11-device-status-attributes.md` to reflect the broader
102
+ (not pump-only) scope.
103
+
104
+ - **Added pump status/capability reads**: `get_pump_flow_range()`
105
+ (`MinimumGallonsPerHour`/`MaximumGallonsPerHour`, 707/708 -- confirmed
106
+ NOT to apply to VectraV1 pumps), `get_pump_override_mode()` (new
107
+ `PumpOverrideMode` enum, attribute 705 -- confidence note: confirmed
108
+ only as a *write* target in the decompile, never read; this library's
109
+ read path is a reasonable inference, not directly observed),
110
+ `get_battery_backup_info()` (`BatteryBackupSpeed`/`BatteryBackupMaxSpeed`,
111
+ 706/701), `get_boosted_battery_info()` (`BoostedBatteryPower`/`OnTime`/
112
+ `OffTime`, 803/804/805). New `mobius.pump_status` module
113
+ (`PumpFlowRange`, `BatteryBackupInfo`, `BoostedBatteryInfo`). All fail
114
+ soft (return `None`) for unsupported/model-dependent attributes rather
115
+ than raising. 9 new tests. See
116
+ `documentation/11-device-status-attributes.md`.
117
+
118
+ - **Added full hyperdrive support** (correct percentage conversion for
119
+ light channels boosted above their normal 100% rating). New
120
+ `mobius.power` module (`ChannelPowerInfo`, `channel_percent_value()`,
121
+ ported from `LightPowerInfo.java`) and `get_channel_power_info()`
122
+ (`MaxPower`/`NormalPower`, attributes 1504/1513). New
123
+ `get_current_light_percentages()` convenience method returns corrected
124
+ 0-100+ percentages instead of raw permille, flagging boosted channels via
125
+ `.diagnostics["hyperdrive_channels"]`. Confirmed byte-identical to the
126
+ old `raw/10` shortcut for the common (non-boosted) case; only changes
127
+ results for genuinely boosted channels. `LightPowerInfo`'s Prime/Hydra-
128
+ specific `gm8b4()` wattage override table is not replicated -- see
129
+ `documentation/06-light-schedule.md`.
130
+ - Also refactored `get_attribute()` into a thin wrapper around a new
131
+ `get_attribute_raw()`, which preserves the device-reported response
132
+ index -- required for `MaxPower`/`NormalPower`, where the index *is*
133
+ the `VisualID` byte value, not just a positional offset. Added a
134
+ regression test proving this distinction matters (a device that
135
+ starts its response at a non-zero index would have silently
136
+ mismatched values to the wrong channels under the old approach).
137
+ - 15 new tests across `tests/test_power.py` and
138
+ `tests/test_hyperdrive_device.py`.
139
+
140
+ - **Added Insolation detection/flagging.** Insolation itself is not
141
+ implemented (it's more than a missing modifier -- when active, the app
142
+ substitutes an entirely different, dynamically-generated schedule
143
+ before interpolating, not something layered on top the way lunar/
144
+ acclimation are). `get_current_light_intensities()` now returns a
145
+ `LightIntensityResult` -- a fully backward-compatible dict subclass with
146
+ an added `.diagnostics["insolation_active"]` flag, via a new
147
+ `get_insolation_enabled()` (`InsolationEnabled`, attribute 912).
148
+
149
+ - **Fixed a real accuracy bug**: `get_current_light_intensities()`
150
+ previously returned only the raw per-channel interpolated curve, missing
151
+ a schedule-level master intensity scalar (`Schedule1Intensity`, attribute
152
+ 511) that the real app applies on top. Caught by direct comparison
153
+ against real app screenshots showing different numbers than this
154
+ library computed for the same light at the same time. Traced to
155
+ `WidgetViewCell.java`/`IntensityView.java` in the decompiled source.
156
+ Added `get_schedule_intensity()`; `get_current_light_intensities()` now
157
+ applies it automatically.
158
+ - **Added lunar-phase and acclimation-ramp support**, the two remaining
159
+ modifiers flagged as gaps in the previous entry. New `mobius.modifiers`
160
+ module, ported from `LunarInfo.java`/`AcclimationInfo.java`:
161
+ - `lunar_percent_reduction(date)` -- verified against real,
162
+ independently-confirmed 2026 moon phase dates (full moon Jan 3, new
163
+ moon Jan 18); the ported algorithm matches exactly.
164
+ - `AcclimationInfo` -- linear intensity ramp from a starting percentage
165
+ up to full over a configured period.
166
+ - `is_night_segment()` -- ported from the boolean half of
167
+ `PointSchedule.getIntensitiesAtTime()`; confirmed this is specifically
168
+ "the dusk-to-night transition segment," not simply "is it night."
169
+ - `get_lunar_enabled()`, `get_acclimation_info()`,
170
+ `get_current_light_intensities()` now applies the full branching the
171
+ app itself uses (night+lunar / night-no-lunar / day+active-acclimation
172
+ / day-no-acclimation).
173
+ - "Insolation" (a real per-calendar-day natural daylight simulation,
174
+ likely a paid/premium feature -- see `mobius.modifiers` docstring for
175
+ details) is a separate, rarer modifier the app also layers in; still
176
+ not implemented -- see `documentation/06-light-schedule.md`.
177
+ - 19 new tests across `tests/test_modifiers.py` and
178
+ `tests/test_light_intensity_branching.py`.
179
+
180
+ ## 0.1.2
181
+
182
+ - Added `manufacturer_for_model()` / `MODEL_MANUFACTURER` (ported from
183
+ `M.Model.getManufacturer()`) — surfaces "EcoTech Marine" /
184
+ "AquaIllumination" / "Neptune Systems" / "NYOS" per device model. Now
185
+ included automatically in `get_device_info()`'s `"manufacturer"` field.
186
+ Added while building a Home Assistant integration on top of this library,
187
+ which needs a manufacturer string for its device registry.
188
+ - `get_device_info()`'s `"serial"` field is now decoded as ASCII text
189
+ instead of hex-encoded raw bytes (e.g. `"7V4Z00F143RBED"` instead of
190
+ `"3756345a30304631343352424544"`). Confirmed printable ASCII on every
191
+ device tested, via cross-validation against the same serial parsed from
192
+ BLE advertisement manufacturer data. Falls back to hex for any
193
+ non-printable-ASCII edge case not yet observed. Found while building the
194
+ Home Assistant integration on top of this library -- the hex-encoded
195
+ serial was producing unnecessarily long/ugly entity IDs.
196
+ - `get_pump_telemetry()` now includes a confirmed `"speed_percent"` field
197
+ alongside the existing raw `"speed"`. `MotorSpeed` is confirmed (via the
198
+ decompiled app's own display code) to be a percentage of max pump power
199
+ in tenths of a percent, NOT RPM -- `String.format("%.0f%%",
200
+ Math.abs(currentSpeed / 10.0f))` is literally what the app does to this
201
+ exact value. `speed_percent` is that same `abs(speed)/10` conversion;
202
+ `speed` is still returned raw since its sign encodes rotation direction
203
+ (reverse), which `speed_percent` discards.
204
+ - Fixed a real connection leak in `MobiusDevice.connect()`: if
205
+ `start_notify()` failed after the BLE connection was already
206
+ established (a known common flaky point right after a fresh connection),
207
+ the exception propagated out of `connect()` (`__aenter__`) -- and per the
208
+ async context manager protocol, a raising `__aenter__` means `__aexit__`
209
+ (and therefore `disconnect()`) is never called by the caller's `async
210
+ with` block. The already-open connection was left orphaned on the
211
+ adapter. Since callers like Home Assistant coordinators retry
212
+ periodically, this leak compounded over repeated failures, plausibly
213
+ exhausting a real adapter's connection-slot pool over time even with no
214
+ other integration competing for it and without any range/signal issue.
215
+ `connect()` now cleans up (disconnects) before re-raising if anything
216
+ fails after the connection is established. Added regression tests
217
+ confirming both the cleanup-on-failure and no-spurious-disconnect-on-
218
+ success behavior.
219
+
220
+ ## 0.1.0 — initial release
221
+
222
+ - Core FSCI/C2CI wire protocol: framing, CRC16, Get/Set-attribute codec.
223
+ Verified byte-for-byte against real captured packets.
224
+ - Scene control (`start_scene`, `start_feed_mode`, `resume_schedule`).
225
+ - Pump telemetry (speed, estimated GPH) — verified live against real
226
+ VorTech MP40QD pumps.
227
+ - Pump schedule reading (mode + parameters per time block) — verified live.
228
+ - Light schedule reading + client-side interpolation, matching the app's
229
+ own behavior — verified live against real Radion XR15 G6 Pro lights.
230
+ - Device discovery, identity (`get_device_info`), and a unified
231
+ `get_device_summary()` with explicit support tiers
232
+ (light / pump / pump-experimental / unsupported).
233
+ - Advertisement-only discovery: model, serial, and `pan_id` (tank/mesh
234
+ grouping) with no GATT connection required — verified against 4 real
235
+ devices sharing one `pan_id`.
236
+ - `mobius-scan` CLI.
237
+
238
+ ### Known gaps
239
+
240
+ - `DoseV1` and `HotSauceV1` primitive types have no parser (see
241
+ `documentation/10-known-gaps-and-open-questions.md`).
242
+ - `CoffeeV1` (NYOS Quantum) pump-primitive support is structurally
243
+ plausible but unverified against real hardware.
244
+ - Thread/CoAP device-to-device relay (used by the official app to reach
245
+ devices without a direct BLE connection) is not implemented — this
246
+ 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.2
3
+ Version: 0.1.4
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
@@ -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
 
@@ -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.