python-mobius 0.4.4__tar.gz → 0.6.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. {python_mobius-0.4.4 → python_mobius-0.6.0}/CHANGELOG.md +81 -16
  2. {python_mobius-0.4.4 → python_mobius-0.6.0}/PKG-INFO +43 -8
  3. {python_mobius-0.4.4 → python_mobius-0.6.0}/README.md +42 -7
  4. {python_mobius-0.4.4 → python_mobius-0.6.0}/documentation/00-overview.md +4 -2
  5. {python_mobius-0.4.4 → python_mobius-0.6.0}/documentation/02-framing-and-crc.md +1 -1
  6. {python_mobius-0.4.4 → python_mobius-0.6.0}/documentation/03-attributes-and-opcodes.md +6 -2
  7. {python_mobius-0.4.4 → python_mobius-0.6.0}/documentation/04-device-identity.md +1 -1
  8. {python_mobius-0.4.4 → python_mobius-0.6.0}/documentation/05-scenes.md +2 -2
  9. {python_mobius-0.4.4 → python_mobius-0.6.0}/documentation/06-light-schedule.md +118 -31
  10. {python_mobius-0.4.4 → python_mobius-0.6.0}/documentation/07-pump-schedule.md +7 -7
  11. python_mobius-0.6.0/documentation/08-manufacturer-data.md +95 -0
  12. {python_mobius-0.4.4 → python_mobius-0.6.0}/documentation/09-thread-coap-relay.md +83 -4
  13. {python_mobius-0.4.4 → python_mobius-0.6.0}/documentation/10-known-gaps-and-open-questions.md +58 -0
  14. {python_mobius-0.4.4 → python_mobius-0.6.0}/documentation/11-device-status-attributes.md +106 -5
  15. {python_mobius-0.4.4 → python_mobius-0.6.0}/documentation/12-device-identity-and-address-stability.md +5 -11
  16. python_mobius-0.6.0/documentation/13-attribute-dump.md +246 -0
  17. python_mobius-0.6.0/documentation/14-batched-attribute-reads.md +80 -0
  18. {python_mobius-0.4.4 → python_mobius-0.6.0}/pyproject.toml +1 -1
  19. {python_mobius-0.4.4 → python_mobius-0.6.0}/src/mobius/__init__.py +16 -7
  20. {python_mobius-0.4.4 → python_mobius-0.6.0}/src/mobius/cli.py +254 -6
  21. {python_mobius-0.4.4 → python_mobius-0.6.0}/src/mobius/coap.py +18 -14
  22. {python_mobius-0.4.4 → python_mobius-0.6.0}/src/mobius/constants.py +271 -63
  23. python_mobius-0.6.0/src/mobius/device.py +3144 -0
  24. python_mobius-0.6.0/src/mobius/device_status.py +751 -0
  25. {python_mobius-0.4.4 → python_mobius-0.6.0}/src/mobius/discovery.py +16 -9
  26. python_mobius-0.6.0/src/mobius/dump.py +378 -0
  27. {python_mobius-0.4.4 → python_mobius-0.6.0}/src/mobius/frame.py +98 -2
  28. python_mobius-0.6.0/src/mobius/manufacturer.py +73 -0
  29. {python_mobius-0.4.4 → python_mobius-0.6.0}/src/mobius/mesh_address.py +2 -2
  30. {python_mobius-0.4.4 → python_mobius-0.6.0}/src/mobius/modifiers.py +13 -10
  31. {python_mobius-0.4.4 → python_mobius-0.6.0}/src/mobius/power.py +9 -8
  32. {python_mobius-0.4.4 → python_mobius-0.6.0}/src/mobius/relay.py +98 -6
  33. {python_mobius-0.4.4 → python_mobius-0.6.0}/src/mobius/schedule.py +5 -6
  34. python_mobius-0.6.0/tests/test_attributes_batch.py +122 -0
  35. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_coap.py +7 -7
  36. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_connection_cleanup.py +61 -2
  37. python_mobius-0.6.0/tests/test_device_control.py +59 -0
  38. python_mobius-0.6.0/tests/test_device_status.py +470 -0
  39. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_device_summary.py +47 -2
  40. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_device_time.py +151 -0
  41. python_mobius-0.6.0/tests/test_discovery_company_ids.py +107 -0
  42. python_mobius-0.6.0/tests/test_dump.py +290 -0
  43. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_firmware_versions.py +82 -6
  44. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_light_intensity_branching.py +165 -16
  45. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_light_schedule.py +1 -3
  46. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_manufacturer.py +26 -0
  47. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_manufacturer_lookup.py +5 -7
  48. python_mobius-0.6.0/tests/test_metadata_batch.py +321 -0
  49. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_modifiers.py +1 -2
  50. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_networked_thread_devices.py +13 -16
  51. python_mobius-0.6.0/tests/test_new_diagnostic_attributes.py +694 -0
  52. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_pump_schedule.py +5 -5
  53. python_mobius-0.6.0/tests/test_pump_telemetry.py +133 -0
  54. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_relay.py +235 -1
  55. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_serial_identity.py +6 -5
  56. python_mobius-0.6.0/tests/test_supported_attributes.py +245 -0
  57. python_mobius-0.4.4/documentation/08-manufacturer-data.md +0 -67
  58. python_mobius-0.4.4/src/mobius/device.py +0 -1677
  59. python_mobius-0.4.4/src/mobius/device_status.py +0 -186
  60. python_mobius-0.4.4/src/mobius/manufacturer.py +0 -53
  61. python_mobius-0.4.4/tests/test_device_status.py +0 -167
  62. {python_mobius-0.4.4 → python_mobius-0.6.0}/.forgejo/scripts/extract_changelog_section.py +0 -0
  63. {python_mobius-0.4.4 → python_mobius-0.6.0}/.forgejo/workflows/release.yml +0 -0
  64. {python_mobius-0.4.4 → python_mobius-0.6.0}/.gitignore +0 -0
  65. {python_mobius-0.4.4 → python_mobius-0.6.0}/LICENSE +0 -0
  66. {python_mobius-0.4.4 → python_mobius-0.6.0}/documentation/01-ble-transport.md +0 -0
  67. {python_mobius-0.4.4 → python_mobius-0.6.0}/src/mobius/crc.py +0 -0
  68. {python_mobius-0.4.4 → python_mobius-0.6.0}/src/mobius/pump_status.py +0 -0
  69. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_coap_indication_handling.py +0 -0
  70. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_frame.py +0 -0
  71. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_frame_sniffer.py +0 -0
  72. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_hyperdrive_device.py +0 -0
  73. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_insolation_detection.py +0 -0
  74. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_mesh_address.py +0 -0
  75. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_mesh_discovery.py +0 -0
  76. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_mesh_discovery_direct_connect.py +0 -0
  77. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_power.py +0 -0
  78. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_pump_status.py +0 -0
  79. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_schedule_intensity_scalar.py +0 -0
  80. {python_mobius-0.4.4 → python_mobius-0.6.0}/tests/test_serial_decoding.py +0 -0
@@ -1,5 +1,69 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ - get_metadata_batch() now falls back to individual reads if the
6
+ batched request itself fails (e.g. no batch support at all) --
7
+ never returns less than those would have. New `used_batch` field,
8
+ `force_individual_reads` param.
9
+ - Added get_metadata_batch() -- one round-trip for AdvancedFeatures/
10
+ CalibrationInfo/hardware/firmware/channels/error state/time,
11
+ confirmed 6-14x faster than individual reads over relay.
12
+ - Wired set_advanced_features() into cli.py -- one flag per field
13
+ (--set-local-control-enabled/--set-auto-dim-timeout/
14
+ --set-max-fan-speed/--set-fan-shutdown-enabled).
15
+ - Added set_advanced_features() -- writes LocalControlEnabled/
16
+ AutoDimTimeout/MaxFanSpeed/FanShutdownEnabled, whichever are given.
17
+ - get_current_light_intensities() now uses get_app_lunar_date(),
18
+ deliberately replicating a confirmed app bug in its lunar
19
+ calculation instead of the physically correct date.
20
+ - Fixed RelayedMobiusDevice missing overrides for
21
+ get_supported_attributes()/get_attributes_batch() -- both bypassed
22
+ the relay path entirely.
23
+ - Fixed --dump-attributes/--set-time-to-now in cli.py ignoring
24
+ --relay-target.
25
+ - Added get_attributes_batch() -- reads multiple attributes in one
26
+ round-trip.
27
+ - Added get_supported_attributes()/dump_attributes()/
28
+ enrich_attribute_dump(), replicating the app's own attribute-dump
29
+ feature. New CLI flags: --dump-attributes/--parse-dump.
30
+
31
+ ## 0.5.0
32
+
33
+ - get_pump_telemetry() now returns gph_reliable/minimum_gph/maximum_gph
34
+ -- confirmed via the app's own logic that a raw gph reading isn't
35
+ trustworthy without a supported flow range (a real AI Axis 20
36
+ reported ~8x its own rated max flow).
37
+ - Added support for AquaIllumination devices' own BLE company ID
38
+ (0x0001, alongside EcoTech Marine's 0x0202) -- confirmed via a real
39
+ AI Axis 20 capture. BREAKING: MOBIUS_COMPANY_ID renamed to
40
+ MOBIUS_COMPANY_ID_ECOTECH.
41
+ - Added get_lunar_phase_info(): the device's own firmware-computed
42
+ lunar state, confirmed against real hardware, wired into
43
+ --dump-schedule for comparing against the calculated lunar value.
44
+ - Fixed get_lunar_phase_info() silently returning None on a real read
45
+ failure (indistinguishable from "unsupported") -- now only an empty
46
+ response does that; an actual failure propagates.
47
+ - Documented a confirmed bug in the app itself (double timezone
48
+ application) that can make its own displayed lunar value roll over
49
+ to the next day early -- this library's own calculation is correct
50
+ and unaffected.
51
+ - Added get_advanced_features(): VorTech's "Local Control"/"Led Auto
52
+ Dim" and Radion's "Max Fan Speed"/"Fan Shutdown".
53
+ - Added motor_power_watts to get_pump_telemetry() (Vectra/NYOS Quantum).
54
+ - Added get_vectra_info() and get_coffee_info() (Vectra/NYOS Quantum
55
+ settings, no real hardware to verify against).
56
+ - Fixed get_firmware_versions() showing raw internal names instead of
57
+ proper labels for AquaIllumination devices (Orbit/Axis, Prime/Hydra).
58
+ - RelayedMobiusDevice now fails fast on later reads in the same poll
59
+ cycle after one relay timeout, instead of each one separately
60
+ waiting out its own timeout.
61
+ - Added set_time_to_now() -- writes Epoch with group=1, confirmed
62
+ against real hardware to propagate to the rest of a tank's mesh.
63
+ - Added reboot() -- soft-reboots a device (Reset attribute), matching
64
+ the app's own "Restart" button. Confirmed against real hardware
65
+ directly connected.
66
+
3
67
  ## 0.4.4
4
68
 
5
69
  - Filled in the rest of the device model list (EcoTech Marine,
@@ -244,9 +308,8 @@
244
308
 
245
309
  - **`get_hardware_info()` now decodes `Color`/`ProductType`/`RadioType`/
246
310
  `MotorType` into confirmed display labels**, instead of returning raw
247
- bytes. Each is itself a confirmed enum with confirmed labels
248
- (`M.Color`/`M.ProductType`/`M.RadioType`/`M.MotorType` in the
249
- decompile) -- added `Color`, `RadioType`, `MotorType`, `ProductType`
311
+ bytes. Each is itself a confirmed enum with confirmed labels --
312
+ added `Color`, `RadioType`, `MotorType`, `ProductType`
250
313
  enums plus their `*_LABELS` dicts to `mobius.constants`. An
251
314
  unrecognized value falls back to `"Unknown (N)"` rather than raising.
252
315
  `Revision`/`Segments` still have no confirmed enum meaning -- returned
@@ -298,8 +361,8 @@ branch has been merged into main as part of this release.
298
361
  is a bare, meaningless ack the app itself discards, confirmed
299
362
  byte-for-byte via real hardware testing showing exactly 1 byte in every
300
363
  relayed read's outer response), matched purely by CoAP token
301
- (confirmed via reverse engineering: `coapRequest.getToken() ==
302
- coapResponse.getToken()`), never by the outer FSCI message ID. Added
364
+ (confirmed via reverse engineering: the request's own token must
365
+ match the response's own token), never by the outer FSCI message ID. Added
303
366
  `MobiusDevice._register_coap_token()`/`wait_for_coap_response()` -- a
304
367
  token-keyed pending-response registry, entirely separate from the
305
368
  existing message-ID-based `self._pending` mechanism -- wired into
@@ -417,7 +480,8 @@ branch has been merged into main as part of this release.
417
480
  (`get_device_summary()`, `get_current_light_intensities()`, etc.) works
418
481
  transparently through relay, confirmed with a test that specifically
419
482
  exercises an inherited high-level method rather than just the
420
- overridden primitives. Confirmed `request.getData(true)` builds a
483
+ overridden primitives. Confirmed via reverse engineering the app that its
484
+ own request wrapping builds a
421
485
  complete self-contained FSCI frame (byte-for-byte identical in
422
486
  structure to this library's own `build_frame()`), resolving an
423
487
  ambiguity flagged in an earlier commit -- relaying a request needed no
@@ -529,10 +593,11 @@ branch has been merged into main as part of this release.
529
593
 
530
594
  - **Major fix: device identity is now serial-number-based, not BLE-address-based.**
531
595
  Real-world testing surfaced a device whose advertised MAC address changed
532
- between runs. Investigating the decompiled app's own `Peripheral` class
596
+ between runs. Investigating the app via reverse engineering
533
597
  confirmed the official app never relies on address stability at all --
534
- `Peripheral.equals()`/`hashCode()` are based purely on serial number, and
535
- `Peripheral.update()` explicitly replaces the stored BLE address whenever
598
+ its own equality and hash logic for a discovered device are based purely
599
+ on serial number, and
600
+ its own update logic explicitly replaces the stored BLE address whenever
536
601
  a new scan matches an existing device by serial. This library previously
537
602
  assumed address stability throughout.
538
603
  - New `find_device_by_serial()` and `dedupe_by_serial()`
@@ -609,8 +674,8 @@ branch has been merged into main as part of this release.
609
674
  0-100+ percentages instead of raw permille, flagging boosted channels via
610
675
  `.diagnostics["hyperdrive_channels"]`. Confirmed byte-identical to the
611
676
  old `raw/10` shortcut for the common (non-boosted) case; only changes
612
- results for genuinely boosted channels. `LightPowerInfo`'s Prime/Hydra-
613
- specific `gm8b4()` wattage override table is not replicated -- see
677
+ results for genuinely boosted channels. A confirmed Prime/Hydra-
678
+ specific wattage override table is not replicated -- see
614
679
  `documentation/06-light-schedule.md`.
615
680
  - Also refactored `get_attribute()` into a thin wrapper around a new
616
681
  `get_attribute_raw()`, which preserves the device-reported response
@@ -680,11 +745,11 @@ branch has been merged into main as part of this release.
680
745
  Home Assistant integration on top of this library -- the hex-encoded
681
746
  serial was producing unnecessarily long/ugly entity IDs.
682
747
  - `get_pump_telemetry()` now includes a confirmed `"speed_percent"` field
683
- alongside the existing raw `"speed"`. `MotorSpeed` is confirmed (via the
684
- decompiled app's own display code) to be a percentage of max pump power
685
- in tenths of a percent, NOT RPM -- `String.format("%.0f%%",
686
- Math.abs(currentSpeed / 10.0f))` is literally what the app does to this
687
- exact value. `speed_percent` is that same `abs(speed)/10` conversion;
748
+ alongside the existing raw `"speed"`. `MotorSpeed` is confirmed (via
749
+ reverse engineering the app's own display code) to be a percentage of max pump power
750
+ in tenths of a percent, NOT RPM -- the app's own display logic formats
751
+ this same value with the exact same abs-value, tenths-to-percent
752
+ conversion. `speed_percent` is that same `abs(speed)/10` conversion;
688
753
  `speed` is still returned raw since its sign encodes rotation direction
689
754
  (reverse), which `speed_percent` discards.
690
755
  - Fixed a real connection leak in `MobiusDevice.connect()`: if
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: python-mobius
3
- Version: 0.4.4
3
+ Version: 0.6.0
4
4
  Summary: Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)
5
5
  Project-URL: Homepage, https://code.r3pek.org/r3pek/python-mobius
6
6
  Project-URL: Documentation, https://code.r3pek.org/r3pek/python-mobius/src/branch/main/documentation
@@ -49,12 +49,18 @@ inferred/experimental.
49
49
  ## Status
50
50
 
51
51
  Alpha. Core protocol (framing, CRC, attribute get/set, scenes), pump
52
- telemetry, pump schedules, light schedules, and device discovery/grouping
53
- are implemented and verified against real hardware (two VorTech MP40QD
54
- pumps, two Radion XR15 G6 Pro lights). See
52
+ telemetry, pump schedules, light schedules, device discovery/grouping
53
+ (both EcoTech Marine's and AquaIllumination's own BLE company IDs), and
54
+ Thread/CoAP relay (reading a non-gateway tank member through the
55
+ gateway's own connection) are implemented and verified against real
56
+ hardware (two VorTech MP40QD pumps, two Radion XR15 G6 Pro lights, one
57
+ AquaIllumination Axis 20 pump). Two write operations are also confirmed
58
+ against real hardware: rebooting a device, and syncing a device's own
59
+ clock to the current time. See
55
60
  [`documentation/10-known-gaps-and-open-questions.md`](./documentation/10-known-gaps-and-open-questions.md)
56
- for what isn't covered yet (dosers, environmental sensors, Thread/CoAP
57
- device relay).
61
+ for what isn't covered yet (dosers, environmental sensors) or is
62
+ implemented but not yet verified against real hardware (Vectra/NYOS
63
+ Quantum-specific settings).
58
64
 
59
65
  ## Install
60
66
 
@@ -96,14 +102,34 @@ mobius-scan --adapter hci0
96
102
  - **Discover devices** and group them by tank/mesh (`pan_id`), reading
97
103
  model/serial straight from BLE advertisements — no connection required.
98
104
  - **Read pump telemetry**: current speed, estimated flow (GPH), operation
99
- state, error state.
105
+ state, error state, and (Vectra/NYOS Quantum pumps) motor power in watts.
100
106
  - **Read pump schedules**: which mode (constant speed, tidal swell, pulse,
101
107
  etc.) is active at any given time, exactly as programmed.
102
108
  - **Read light schedules**: per-channel intensity at any given time,
103
109
  replicating the app's own client-side interpolation (there's no "current
104
110
  intensity" attribute — lights only expose the programmed curve).
111
+ - **Read device-specific settings**: VorTech's own "Local Control"/"Led
112
+ Auto Dim" and Radion's own "Max Fan Speed"/"Fan Shutdown"
113
+ (`get_advanced_features()`), plus Vectra and NYOS Quantum settings
114
+ (`get_vectra_info()`/`get_coffee_info()` — no real hardware to verify
115
+ either against, see
116
+ [known gaps](./documentation/10-known-gaps-and-open-questions.md)).
105
117
  - **Control scenes**: start feed mode, resume the normal schedule, or any
106
118
  other configured scene.
119
+ - **Fix a desynced device clock** (`set_time_to_now()`) — a WRITE.
120
+ Writing to one device appears to propagate to the rest of its Thread
121
+ mesh too, confirmed against real hardware — see
122
+ [09-thread-coap-relay.md](./documentation/09-thread-coap-relay.md)
123
+ for what's confirmed and what isn't yet.
124
+ - **Reboot a device** (`reboot()`) — a WRITE, matching the app's own
125
+ "Restart" button exactly. Confirmed against real hardware directly
126
+ connected; not yet confirmed via relay.
127
+ - **Dump every attribute a device supports** (`dump_attributes()`),
128
+ matching the app's own Settings → Troubleshoot diagnostic feature's
129
+ data collection — confirmed against real hardware. Output is enriched
130
+ JSON (resolved names, decoded values where known), not the app's own
131
+ plain-text format. See
132
+ [13-attribute-dump.md](./documentation/13-attribute-dump.md).
107
133
  - **Low-level protocol access** (`build_frame`, `get_attribute`,
108
134
  `set_attribute`, ...) if you want to go beyond what's wrapped in
109
135
  `MobiusDevice`.
@@ -113,13 +139,22 @@ mobius-scan --adapter hci0
113
139
  | PrimitiveType | Support | Notes |
114
140
  |---|---|---|
115
141
  | `VisualV1` (Radion, Prime, Hydra, etc.) | ✅ Verified | Lights |
116
- | `VorTechV1`, `PumpV1`, `VectraV1`, `AlpacaV1`, `TurtleV1` | ✅ Verified | Pumps |
142
+ | `VorTechV1`, `TurtleV1` (AquaIllumination Axis) | ✅ Verified | Pumps -- confirmed against real hardware directly (VorTech MP40QD, AquaIllumination Axis 20) |
143
+ | `PumpV1`, `VectraV1`, `AlpacaV1` (AquaIllumination Orbit) | ✅ Verified | Pumps -- same wire format as the primitives above, not independently confirmed against their own real hardware |
117
144
  | `CoffeeV1` (NYOS Quantum) | ⚠️ Experimental | Same wire structure as pumps per the protocol, untested against real hardware |
118
145
  | `DoseV1`, `HotSauceV1` | ❌ Unsupported | Different primitive format; identity info only |
119
146
 
120
147
  `MobiusDevice.get_device_summary()` always tells you which tier applies via
121
148
  its `"support"` field — see [`documentation/04-device-identity.md`](./documentation/04-device-identity.md).
122
149
 
150
+ "Verified" above is about core telemetry/schedule parsing. The
151
+ Vectra/NYOS Quantum-specific settings methods
152
+ (`get_vectra_info()`/`get_coffee_info()`) and `motor_power_watts` are
153
+ newer, implemented from the decompiled source alone, and not verified
154
+ against real Vectra or NYOS Quantum hardware regardless of the table
155
+ above — see
156
+ [known gaps](./documentation/10-known-gaps-and-open-questions.md).
157
+
123
158
  ## Development
124
159
 
125
160
  ```bash
@@ -17,12 +17,18 @@ inferred/experimental.
17
17
  ## Status
18
18
 
19
19
  Alpha. Core protocol (framing, CRC, attribute get/set, scenes), pump
20
- telemetry, pump schedules, light schedules, and device discovery/grouping
21
- are implemented and verified against real hardware (two VorTech MP40QD
22
- pumps, two Radion XR15 G6 Pro lights). See
20
+ telemetry, pump schedules, light schedules, device discovery/grouping
21
+ (both EcoTech Marine's and AquaIllumination's own BLE company IDs), and
22
+ Thread/CoAP relay (reading a non-gateway tank member through the
23
+ gateway's own connection) are implemented and verified against real
24
+ hardware (two VorTech MP40QD pumps, two Radion XR15 G6 Pro lights, one
25
+ AquaIllumination Axis 20 pump). Two write operations are also confirmed
26
+ against real hardware: rebooting a device, and syncing a device's own
27
+ clock to the current time. See
23
28
  [`documentation/10-known-gaps-and-open-questions.md`](./documentation/10-known-gaps-and-open-questions.md)
24
- for what isn't covered yet (dosers, environmental sensors, Thread/CoAP
25
- device relay).
29
+ for what isn't covered yet (dosers, environmental sensors) or is
30
+ implemented but not yet verified against real hardware (Vectra/NYOS
31
+ Quantum-specific settings).
26
32
 
27
33
  ## Install
28
34
 
@@ -64,14 +70,34 @@ mobius-scan --adapter hci0
64
70
  - **Discover devices** and group them by tank/mesh (`pan_id`), reading
65
71
  model/serial straight from BLE advertisements — no connection required.
66
72
  - **Read pump telemetry**: current speed, estimated flow (GPH), operation
67
- state, error state.
73
+ state, error state, and (Vectra/NYOS Quantum pumps) motor power in watts.
68
74
  - **Read pump schedules**: which mode (constant speed, tidal swell, pulse,
69
75
  etc.) is active at any given time, exactly as programmed.
70
76
  - **Read light schedules**: per-channel intensity at any given time,
71
77
  replicating the app's own client-side interpolation (there's no "current
72
78
  intensity" attribute — lights only expose the programmed curve).
79
+ - **Read device-specific settings**: VorTech's own "Local Control"/"Led
80
+ Auto Dim" and Radion's own "Max Fan Speed"/"Fan Shutdown"
81
+ (`get_advanced_features()`), plus Vectra and NYOS Quantum settings
82
+ (`get_vectra_info()`/`get_coffee_info()` — no real hardware to verify
83
+ either against, see
84
+ [known gaps](./documentation/10-known-gaps-and-open-questions.md)).
73
85
  - **Control scenes**: start feed mode, resume the normal schedule, or any
74
86
  other configured scene.
87
+ - **Fix a desynced device clock** (`set_time_to_now()`) — a WRITE.
88
+ Writing to one device appears to propagate to the rest of its Thread
89
+ mesh too, confirmed against real hardware — see
90
+ [09-thread-coap-relay.md](./documentation/09-thread-coap-relay.md)
91
+ for what's confirmed and what isn't yet.
92
+ - **Reboot a device** (`reboot()`) — a WRITE, matching the app's own
93
+ "Restart" button exactly. Confirmed against real hardware directly
94
+ connected; not yet confirmed via relay.
95
+ - **Dump every attribute a device supports** (`dump_attributes()`),
96
+ matching the app's own Settings → Troubleshoot diagnostic feature's
97
+ data collection — confirmed against real hardware. Output is enriched
98
+ JSON (resolved names, decoded values where known), not the app's own
99
+ plain-text format. See
100
+ [13-attribute-dump.md](./documentation/13-attribute-dump.md).
75
101
  - **Low-level protocol access** (`build_frame`, `get_attribute`,
76
102
  `set_attribute`, ...) if you want to go beyond what's wrapped in
77
103
  `MobiusDevice`.
@@ -81,13 +107,22 @@ mobius-scan --adapter hci0
81
107
  | PrimitiveType | Support | Notes |
82
108
  |---|---|---|
83
109
  | `VisualV1` (Radion, Prime, Hydra, etc.) | ✅ Verified | Lights |
84
- | `VorTechV1`, `PumpV1`, `VectraV1`, `AlpacaV1`, `TurtleV1` | ✅ Verified | Pumps |
110
+ | `VorTechV1`, `TurtleV1` (AquaIllumination Axis) | ✅ Verified | Pumps -- confirmed against real hardware directly (VorTech MP40QD, AquaIllumination Axis 20) |
111
+ | `PumpV1`, `VectraV1`, `AlpacaV1` (AquaIllumination Orbit) | ✅ Verified | Pumps -- same wire format as the primitives above, not independently confirmed against their own real hardware |
85
112
  | `CoffeeV1` (NYOS Quantum) | ⚠️ Experimental | Same wire structure as pumps per the protocol, untested against real hardware |
86
113
  | `DoseV1`, `HotSauceV1` | ❌ Unsupported | Different primitive format; identity info only |
87
114
 
88
115
  `MobiusDevice.get_device_summary()` always tells you which tier applies via
89
116
  its `"support"` field — see [`documentation/04-device-identity.md`](./documentation/04-device-identity.md).
90
117
 
118
+ "Verified" above is about core telemetry/schedule parsing. The
119
+ Vectra/NYOS Quantum-specific settings methods
120
+ (`get_vectra_info()`/`get_coffee_info()`) and `motor_power_watts` are
121
+ newer, implemented from the decompiled source alone, and not verified
122
+ against real Vectra or NYOS Quantum hardware regardless of the table
123
+ above — see
124
+ [known gaps](./documentation/10-known-gaps-and-open-questions.md).
125
+
91
126
  ## Development
92
127
 
93
128
  ```bash
@@ -34,7 +34,7 @@ Interface conventions, extended by EcoTech).
34
34
  and two Radion XR15 G6 Pro lights, all sharing one tank. Every field
35
35
  marked "confirmed" in these docs was checked this way — either by
36
36
  successfully round-tripping known values, or by cross-validating two
37
- independent derivations (e.g. a value read via decompiled Java source
37
+ independent derivations (e.g. a value read via a decompiled source
38
38
  matching a value read live over BLE).
39
39
 
40
40
  ## Document map
@@ -53,12 +53,14 @@ Interface conventions, extended by EcoTech).
53
53
  | [10-known-gaps-and-open-questions.md](./10-known-gaps-and-open-questions.md) | Everything not yet confirmed or implemented |
54
54
  | [11-device-status-attributes.md](./11-device-status-attributes.md) | Flow range, override mode, battery backup, group/sync, calibration (light-only), maintenance |
55
55
  | [12-device-identity-and-address-stability.md](./12-device-identity-and-address-stability.md) | Why device identity is serial-number-based, not address-based |
56
+ | [13-attribute-dump.md](./13-attribute-dump.md) | Reading every attribute a device supports, enriched JSON output, the app-compatible text format |
57
+ | [14-batched-attribute-reads.md](./14-batched-attribute-reads.md) | Reading multiple, different attributes in one round-trip |
56
58
 
57
59
  ## On the use of AI assistance
58
60
 
59
61
  The decompiled-source analysis, protocol derivation, and Python
60
62
  implementation in this project were done with substantial assistance from
61
- Claude (Anthropic) — reading the decompiled Java sources, cross-referencing
63
+ Claude (Anthropic) — reading the decompiled app sources, cross-referencing
62
64
  them against the public community research above, resolving several
63
65
  decompilation artifacts specific to the reverse-engineering process (see
64
66
  [03-attributes-and-opcodes.md](./03-attributes-and-opcodes.md) for a
@@ -26,7 +26,7 @@ using this library's own encoder, matching the captured bytes exactly (see
26
26
 
27
27
  ## opGroup values
28
28
 
29
- Java's original constants are signed bytes:
29
+ The app's own original constants are signed bytes:
30
30
 
31
31
  | Name | Signed | Unsigned (hex) |
32
32
  |---|---|---|
@@ -79,14 +79,18 @@ against real pumps and lights returned correctly-structured, sane data.
79
79
  |---|---|---|
80
80
  | `Model` | 4 | See [04-device-identity.md](./04-device-identity.md) |
81
81
  | `Name` | 5 | User-assigned device name |
82
+ | `Reset` | 6 | Write `ResetType.Soft` (0) to reboot -- see [09-thread-coap-relay.md](./09-thread-coap-relay.md) |
82
83
  | `PrimitiveType` | 8 | Device class (pump/light/etc) — see 04 |
83
- | `PhysicalValues` | 101 | Sub-indexed by `PhysicalValueID`; used for live pump GPH |
84
+ | `PhysicalValues` | 101 | Sub-indexed by `PhysicalValueID`; used for live pump GPH and (Vectra/NYOS Quantum) `MotorPower`/watts -- see [11-device-status-attributes.md](./11-device-status-attributes.md) |
84
85
  | `MACAddress` | 103 | Observed unpopulated (empty response) on all devices tested |
85
86
  | `OperationState` | 104 | `OOB`/`LiveDemo`/`Scene`/`Schedule` |
86
87
  | `ErrorState` | 107 | See [04-device-identity.md](./04-device-identity.md) |
87
88
  | `CurrentScene` | 401 | Set to `sceneId(u16 LE) + durationMinutes(u16 LE)` to start a scene |
88
89
  | `Schedule1` / `Schedule2` | 500 / 503 | The programmed point-schedule — see 06/07 |
89
- | `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))`. |
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 formatting. |
91
+ | `LocalControlEnabled` / `AutoDimTimeout` / `MaxFanSpeed` / `FanShutdownEnabled` | 300 / 301 / 910 / 1203 | VorTech/Radion advanced feature settings — see 11 |
92
+ | `PowerOnDelay` / `ClosedLoop` / `FeedModeReturnDelay` | 800 / 801 / 802 | Vectra settings — see 11 |
93
+ | `CoffeeLedOn` / `CoffeeStatusIntensity` / `CoffeeFeedModeReturnDelay` / `CoffeePowerOnDelay` | 717 / 4100 / 4101 / 4102 | NYOS Quantum settings — see 11 |
90
94
  | `SupportedColorChannels` | 901 | Static list of a light's channels |
91
95
 
92
96
  See `src/mobius/constants.py` for the complete implemented list.
@@ -22,7 +22,7 @@ way to tell devices apart over BLE (they all advertise the same local name,
22
22
 
23
23
  ### `PRIMITIVE_SIZE` — schedule-point primitive byte size, per type
24
24
 
25
- Confirmed from `M.PrimitiveType.size()`. For `VisualV1` this is a
25
+ Confirmed from the app's own per-primitive size lookup. For `VisualV1` this is a
26
26
  per-channel repeating unit (3 bytes: `VisualID` + `u16` intensity), not a
27
27
  fixed total. For everything else, it's the exact fixed size of that
28
28
  primitive type's data.
@@ -5,7 +5,7 @@ override the normal schedule temporarily or permanently.
5
5
 
6
6
  ## `SceneID`
7
7
 
8
- Confirmed literal values from `M.SceneID`:
8
+ Confirmed literal values:
9
9
 
10
10
  | Value | Name |
11
11
  |---|---|
@@ -45,7 +45,7 @@ back to schedule-driven.
45
45
 
46
46
  ## `OperationState`
47
47
 
48
- Confirmed literal values from `M.OperationState`:
48
+ Confirmed literal values:
49
49
 
50
50
  | Value | Name |
51
51
  |---|---|
@@ -165,19 +165,17 @@ point. In practice this covers essentially the whole night, *except*
165
165
  the two transition segments themselves: dusk (start point not yet
166
166
  night-flagged) and the sunrise segment (start point flagged `SUNRISE`).
167
167
 
168
- **Confirmed directly against the raw bytecode, not the decompiled Java.**
169
- An earlier decompilation attempt for this
170
- exact method was unreliable -- a real signal, not noise: the decompiled Java had this
171
- condition's first check inverted (`!point.has(NIGHT)` instead of the
172
- actual `point.has(NIGHT)`), which an earlier version of this port
173
- carried forward uncorrected. Found via real hardware testing: a light
174
- in a `23:00[NIGHT,SUNSET] -> 23:27[NIGHT]` segment showed a value
175
- consistent with the schedule-intensity scalar from this library, while
176
- the real app displayed a noticeably lower, lunar-consistent value at
177
- the same moment -- mathematically impossible to explain by
178
- schedule-intensity alone within that segment's raw-value range, which
179
- is what prompted re-examining the raw bytecode instead of trusting the
180
- decompiled Java a second time.
168
+ **Confirmed directly against the raw bytecode, not the decompiled
169
+ source.** The decompiled source for this exact method is unreliable --
170
+ a real signal, not noise: it has this condition's first check inverted
171
+ (`!point.has(NIGHT)` instead of the actual `point.has(NIGHT)`). Found
172
+ via real hardware testing: a light in a `23:00[NIGHT,SUNSET] ->
173
+ 23:27[NIGHT]` segment showed a value consistent with the schedule-
174
+ intensity scalar from this library, while the real app displayed a
175
+ noticeably lower, lunar-consistent value at the same moment --
176
+ mathematically impossible to explain by schedule-intensity alone within
177
+ that segment's raw-value range, which is what prompted re-examining the
178
+ raw bytecode instead of trusting the decompiled source a second time.
181
179
 
182
180
  **Acclimation ramp** (`AcclimationInfo`): a linear intensity ramp from
183
181
  `start_intensity` (0-1000 permille) up to full (1000) over `period_days`,
@@ -189,6 +187,69 @@ completed yet.
189
187
  `get_current_light_intensities()` fetches and applies all of this
190
188
  automatically -- no separate calls needed for the common case.
191
189
 
190
+ **`get_lunar_phase_info()`**: reads `LunarPhasesCurrentDay`/
191
+ `LunarPhasesCurrentScalar` directly off the device -- its own
192
+ firmware-computed lunar state, confirmed against real hardware to
193
+ match `LUNAR_CYCLE_SCALERS[current_day]` exactly. This is a THIRD,
194
+ independent value from the two below -- see the real-hardware findings
195
+ just below for why all three (this one, the app's own display, and
196
+ what the LEDs physically do) can genuinely differ from each other.
197
+
198
+ **A confirmed bug in the app itself -- and this library deliberately
199
+ replicates it.** The app's own `date`-to-calendar-day conversion
200
+ (confirmed via disassembling the actual raw bytecode with androguard,
201
+ not just the decompile -- see `get_app_lunar_date()`'s own docstring
202
+ in device.py for the full instruction-level trace) applies a timezone
203
+ shift TWICE: once converting `Epoch` to `LocalTime` (the device's own
204
+ job), and again via the phone's own equivalent calendar-day
205
+ conversion, reading the PHONE's own
206
+ timezone off a value that's already been shifted once. When phone and
207
+ device agree on timezone (assumed here, since this library can't know
208
+ the phone's own setting), the net effect is the SAME offset applied
209
+ twice -- not once. For UTC+1, that means the app's own effective
210
+ calendar day rolls over **two hours early**, not one -- confirmed by
211
+ working the exact arithmetic (`2 * LocalTime - Epoch`) and
212
+ independently verified against real captured device data spanning
213
+ both a real summer evening and a forced-clock winter test:
214
+
215
+ - **Summer (UTC+1)**: at 23:25 and again at 23:55 local -- both well
216
+ before that night's true midnight -- the app's own formula computed
217
+ the NEXT calendar day's scalar, while the device's own reported
218
+ `LunarPhasesCurrentDay`/`CurrentScalar` were both still on the
219
+ PREVIOUS day at those exact same moments. The two only agreed again
220
+ once true midnight had actually passed.
221
+ - **Winter (UTC+0)**: the formula and the device's own reported values
222
+ agreed exactly at every point tested -- confirming the shift
223
+ magnitude really does scale with the timezone offset, and vanishes
224
+ entirely at zero.
225
+
226
+ `get_current_light_intensities()` uses `get_app_lunar_date()`
227
+ specifically (not `get_device_local_date()`, which is the physically
228
+ correct one) for this calculation, because this method's own stated
229
+ job is to replicate what the app itself shows -- bugs included, not to
230
+ compute ground truth. A `ha-mobius` user comparing this library's
231
+ output against the official app should see them agree, including
232
+ during the window where the app is (confirmedly) wrong about the
233
+ calendar day.
234
+
235
+ **A separate, ALSO real-hardware-confirmed finding: the device's own
236
+ physical LED output doesn't track EITHER of the above continuously.**
237
+ Three real dumps of a real light, spanning an actual midnight
238
+ rollover, cross-checked against the device's own live `Intensity`
239
+ (1500) readback and direct visual/power observation, showed the
240
+ device locks its own scalar in ONCE -- at the moment the evening's
241
+ ramp into the night segment physically completes -- and holds that
242
+ exact value fixed for the rest of the night, regardless of what
243
+ `LunarPhasesCurrentDay`/`CurrentScalar` do afterward. Neither this
244
+ library's own calculation nor the app's own display attempts to model
245
+ this transition-locked behavior; both compute a value continuously,
246
+ as if the physical output tracked the calendar day in real time. It
247
+ doesn't. This is a third, genuinely different value from the two
248
+ above, not a bug in either of them -- there's no confirmed way to
249
+ predict the device's own physical value from `Epoch`/`LocalTime` alone
250
+ without also knowing when the most recent schedule transition into
251
+ the night segment actually completed.
252
+
192
253
  ## Insolation detection (flagged, not implemented)
193
254
 
194
255
  Insolation itself is not implemented (see below), but
@@ -214,22 +275,31 @@ from the manually-programmed one on a given day.
214
275
 
215
276
  `.diagnostics` also includes `"is_night_segment"`, `"lunar_enabled"`,
216
277
  `"scalar_source"` (one of `"lunar"`/`"night_no_lunar"`/
217
- `"schedule_intensity"`), and `"scalar"` (the actual final multiplier
218
- applied) -- useful for telling apart which branch/scalar produced a given
219
- value, e.g. when a computed value doesn't match the app's own displayed
220
- one and it isn't obvious whether `is_night_segment()` misjudged a
221
- schedule's point structure or `get_lunar_enabled()` read incorrectly:
278
+ `"schedule_intensity"`), `"scalar"` (the actual final multiplier
279
+ applied), and, specifically for the `"lunar"` case, `"lunar_date"` (the
280
+ actual date `get_app_lunar_date()` computed -- see that section above
281
+ for why this is deliberately the app's own, confirmed-buggy value, not
282
+ the physically correct one) and `"lunar_date_source"` (one of
283
+ `"app_lunar_date"`/`"local_fallback"`) -- useful for telling apart
284
+ which branch/scalar produced a given value, e.g. when a computed value
285
+ doesn't match the app's own displayed one and it isn't obvious whether
286
+ `is_night_segment()` misjudged a schedule's point structure,
287
+ `get_lunar_enabled()` read incorrectly, or the date computation itself
288
+ went wrong:
222
289
 
223
290
  ```python
224
291
  result = await device.get_current_light_intensities()
225
292
  print(result.diagnostics)
226
293
  # {'insolation_active': False, 'is_night_segment': True,
227
- # 'lunar_enabled': True, 'scalar_source': 'lunar', 'scalar': 0.35}
294
+ # 'lunar_enabled': True, 'scalar_source': 'lunar', 'scalar': 0.6,
295
+ # 'lunar_date': datetime.date(2026, 8, 29), 'lunar_date_source': 'app_lunar_date'}
228
296
  ```
229
297
 
230
298
  `lunar_enabled` is `None` (not `False`) when `is_night_segment` is
231
299
  `False`, since it isn't checked/relevant outside the night segment --
232
- don't read a `None` here as "lunar phases are off."
300
+ don't read a `None` here as "lunar phases are off." Similarly,
301
+ `lunar_date`/`lunar_date_source` are both `None` whenever
302
+ `scalar_source` isn't `"lunar"`.
233
303
 
234
304
  ## Hyperdrive (boosted channels above 100%)
235
305
 
@@ -243,7 +313,7 @@ how much headroom that specific channel actually has.
243
313
  `get_current_light_percentages()` accounts for this: it fetches
244
314
  `MaxPower`/`NormalPower` (`get_channel_power_info()`, attributes `1504`/
245
315
  `1513`) and applies `channel_percent_value()` (`mobius.power`, ported from
246
- `LightPowerInfo.getPercentValue()`) per channel. For the common case --
316
+ the app's own confirmed equivalent logic) per channel. For the common case --
247
317
  non-hyperdrive channels/devices, i.e. any raw value at or below 1000 --
248
318
  this gives the same results as a simple `raw/10`; the extra machinery
249
319
  only changes anything for genuinely boosted channels. It also flags which
@@ -254,22 +324,39 @@ alone (two more `Get`s for `MaxPower`/`NormalPower`). Use
254
324
  `get_current_light_intensities()` directly if hyperdrive accuracy isn't
255
325
  needed, or to minimize round trips.
256
326
 
257
- **Not replicated**: `LightPowerInfo`'s `gm8b4()` lookup table -- a
327
+ **Not replicated**: a lookup table confirmed present in the app -- a
258
328
  hardcoded wattage override for a handful of specific AquaIllumination
259
329
  Prime/Hydra model + channel combinations. Only relevant to those specific
260
330
  models; the general case (including every EcoTech Radion model this
261
331
  library has been verified against) doesn't use it.
262
332
 
263
- ## Not implemented here
333
+ ## The insolation modifier itself -- not implemented here
334
+
335
+ Raw reading of the underlying attributes IS implemented (see
336
+ `get_insolation_tables()`/`InsolationTables` below) -- what's NOT
337
+ implemented is the client-side modifier that would actually use this
338
+ table to adjust intensity based on the calendar day, the way
339
+ `mobius.modifiers`'s own lunar/acclimation modifiers already do for
340
+ their own attributes.
264
341
 
265
342
  - "Insolation" -- a real per-calendar-day natural daylight simulation:
266
- each light stores a 365-entry table (one per day of the year, packed
267
- into 8 bytes via `InsolationTable1`/`InsolationTable2`), where every
268
- entry holds sunrise time, sunset time, sun intensity (0-100%), moonrise
269
- time, moonset time, and moon intensity (0-100%) for that specific
270
- calendar day. Lets a light automatically track real seasonal daylight
343
+ each light stores a 365-entry table (one per day of the year), where
344
+ every entry holds sunrise time, sunset time, sun intensity (0-100%),
345
+ moonrise time, moonset time, and moon intensity (0-100%) for that
346
+ specific calendar day. Confirmed via reverse engineering the app that
347
+ each entry is packed into 8 bytes -- but the actual raw bytes read
348
+ from `InsolationTable1`/`InsolationTable2` (see
349
+ `get_insolation_tables()`/`InsolationTables` in device_status.py) are
350
+ 9 bytes per entry on a real light; this discrepancy is unresolved,
351
+ and since every entry captured so far was all-zero (unconfigured),
352
+ there's no way to independently confirm the field-level byte layout
353
+ either way. `get_insolation_tables()` returns raw per-entry bytes
354
+ rather than decoding this structure, for exactly that reason. Lets a
355
+ light automatically track real seasonal daylight
271
356
  variation instead of running one fixed schedule year-round. Gated
272
- behind `AccountSettings.hasPermission()` in the app -- likely a
273
- paid/premium feature, not merely an obscure one. Not implemented here;
274
- treated as always-disabled.
357
+ behind an account-level permission check in the app -- likely a
358
+ paid/premium feature, not merely an obscure one. The lunar/
359
+ acclimation MODIFIERS in `mobius.modifiers` are implemented and
360
+ confirmed; this separate, rarer modifier is not, and is treated as
361
+ always-disabled by those modifiers.
275
362
  - Writing/programming a schedule (only reading is implemented).