python-mobius 0.5.0__tar.gz → 0.7.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 (78) hide show
  1. {python_mobius-0.5.0 → python_mobius-0.7.0}/CHANGELOG.md +59 -13
  2. {python_mobius-0.5.0 → python_mobius-0.7.0}/PKG-INFO +7 -1
  3. {python_mobius-0.5.0 → python_mobius-0.7.0}/README.md +6 -0
  4. {python_mobius-0.5.0 → python_mobius-0.7.0}/documentation/00-overview.md +4 -2
  5. {python_mobius-0.5.0 → python_mobius-0.7.0}/documentation/02-framing-and-crc.md +1 -1
  6. {python_mobius-0.5.0 → python_mobius-0.7.0}/documentation/06-light-schedule.md +103 -44
  7. {python_mobius-0.5.0 → python_mobius-0.7.0}/documentation/08-manufacturer-data.md +12 -9
  8. {python_mobius-0.5.0 → python_mobius-0.7.0}/documentation/09-thread-coap-relay.md +6 -4
  9. {python_mobius-0.5.0 → python_mobius-0.7.0}/documentation/10-known-gaps-and-open-questions.md +22 -0
  10. {python_mobius-0.5.0 → python_mobius-0.7.0}/documentation/11-device-status-attributes.md +3 -3
  11. {python_mobius-0.5.0 → python_mobius-0.7.0}/documentation/12-device-identity-and-address-stability.md +5 -11
  12. python_mobius-0.7.0/documentation/13-attribute-dump.md +246 -0
  13. python_mobius-0.7.0/documentation/14-batched-attribute-reads.md +80 -0
  14. {python_mobius-0.5.0 → python_mobius-0.7.0}/pyproject.toml +1 -1
  15. {python_mobius-0.5.0 → python_mobius-0.7.0}/src/mobius/__init__.py +16 -7
  16. {python_mobius-0.5.0 → python_mobius-0.7.0}/src/mobius/cli.py +164 -8
  17. {python_mobius-0.5.0 → python_mobius-0.7.0}/src/mobius/coap.py +18 -14
  18. {python_mobius-0.5.0 → python_mobius-0.7.0}/src/mobius/constants.py +186 -65
  19. {python_mobius-0.5.0 → python_mobius-0.7.0}/src/mobius/device.py +1954 -180
  20. python_mobius-0.7.0/src/mobius/device_status.py +751 -0
  21. {python_mobius-0.5.0 → python_mobius-0.7.0}/src/mobius/discovery.py +5 -5
  22. python_mobius-0.7.0/src/mobius/dump.py +378 -0
  23. {python_mobius-0.5.0 → python_mobius-0.7.0}/src/mobius/frame.py +98 -2
  24. {python_mobius-0.5.0 → python_mobius-0.7.0}/src/mobius/manufacturer.py +6 -5
  25. {python_mobius-0.5.0 → python_mobius-0.7.0}/src/mobius/mesh_address.py +2 -2
  26. {python_mobius-0.5.0 → python_mobius-0.7.0}/src/mobius/modifiers.py +7 -4
  27. {python_mobius-0.5.0 → python_mobius-0.7.0}/src/mobius/power.py +9 -8
  28. {python_mobius-0.5.0 → python_mobius-0.7.0}/src/mobius/relay.py +61 -4
  29. python_mobius-0.7.0/tests/test_attributes_batch.py +122 -0
  30. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_coap.py +7 -7
  31. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_device_status.py +98 -1
  32. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_device_time.py +96 -2
  33. python_mobius-0.7.0/tests/test_dump.py +290 -0
  34. python_mobius-0.7.0/tests/test_full_poll_batch.py +229 -0
  35. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_light_intensity_branching.py +83 -11
  36. python_mobius-0.7.0/tests/test_light_poll_batch.py +203 -0
  37. python_mobius-0.7.0/tests/test_metadata_batch.py +321 -0
  38. python_mobius-0.7.0/tests/test_new_diagnostic_attributes.py +694 -0
  39. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_pump_schedule.py +40 -0
  40. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_relay.py +103 -0
  41. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_serial_identity.py +6 -5
  42. python_mobius-0.7.0/tests/test_supported_attributes.py +245 -0
  43. python_mobius-0.5.0/src/mobius/device_status.py +0 -323
  44. {python_mobius-0.5.0 → python_mobius-0.7.0}/.forgejo/scripts/extract_changelog_section.py +0 -0
  45. {python_mobius-0.5.0 → python_mobius-0.7.0}/.forgejo/workflows/release.yml +0 -0
  46. {python_mobius-0.5.0 → python_mobius-0.7.0}/.gitignore +0 -0
  47. {python_mobius-0.5.0 → python_mobius-0.7.0}/LICENSE +0 -0
  48. {python_mobius-0.5.0 → python_mobius-0.7.0}/documentation/01-ble-transport.md +0 -0
  49. {python_mobius-0.5.0 → python_mobius-0.7.0}/documentation/03-attributes-and-opcodes.md +0 -0
  50. {python_mobius-0.5.0 → python_mobius-0.7.0}/documentation/04-device-identity.md +0 -0
  51. {python_mobius-0.5.0 → python_mobius-0.7.0}/documentation/05-scenes.md +0 -0
  52. {python_mobius-0.5.0 → python_mobius-0.7.0}/documentation/07-pump-schedule.md +0 -0
  53. {python_mobius-0.5.0 → python_mobius-0.7.0}/src/mobius/crc.py +0 -0
  54. {python_mobius-0.5.0 → python_mobius-0.7.0}/src/mobius/pump_status.py +0 -0
  55. {python_mobius-0.5.0 → python_mobius-0.7.0}/src/mobius/schedule.py +0 -0
  56. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_coap_indication_handling.py +0 -0
  57. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_connection_cleanup.py +0 -0
  58. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_device_control.py +0 -0
  59. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_device_summary.py +0 -0
  60. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_discovery_company_ids.py +0 -0
  61. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_firmware_versions.py +0 -0
  62. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_frame.py +0 -0
  63. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_frame_sniffer.py +0 -0
  64. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_hyperdrive_device.py +0 -0
  65. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_insolation_detection.py +0 -0
  66. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_light_schedule.py +0 -0
  67. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_manufacturer.py +0 -0
  68. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_manufacturer_lookup.py +0 -0
  69. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_mesh_address.py +0 -0
  70. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_mesh_discovery.py +0 -0
  71. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_mesh_discovery_direct_connect.py +0 -0
  72. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_modifiers.py +0 -0
  73. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_networked_thread_devices.py +0 -0
  74. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_power.py +0 -0
  75. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_pump_status.py +0 -0
  76. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_pump_telemetry.py +0 -0
  77. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_schedule_intensity_scalar.py +0 -0
  78. {python_mobius-0.5.0 → python_mobius-0.7.0}/tests/test_serial_decoding.py +0 -0
@@ -1,5 +1,49 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.7.0
4
+
5
+ - Added get_full_poll_batch() -- one round-trip for device identity +
6
+ metadata + light/pump state, confirmed 2-2.6x faster on real
7
+ hardware. Requires the caller to already know the device's own
8
+ PrimitiveType (permanent for its lifetime, so never re-fetched).
9
+ - get_current_pump_block() now accepts an optional pre-fetched
10
+ `points` param, skipping its own redundant re-fetch of the same
11
+ schedule attribute.
12
+ - Added get_light_poll_batch() -- one round-trip for a light's own
13
+ schedule, lunar/acclimation/insolation state, and Epoch/LocalTime,
14
+ falling back to the original individual reads if the batch fails.
15
+ - Extracted every light-schedule-related method's own decode/compute
16
+ logic into pure, standalone functions, laying the groundwork for
17
+ get_light_poll_batch() above. No behavior change.
18
+
19
+ ## 0.6.0
20
+
21
+ - get_metadata_batch() now falls back to individual reads if the
22
+ batched request itself fails (e.g. no batch support at all) --
23
+ never returns less than those would have. New `used_batch` field,
24
+ `force_individual_reads` param.
25
+ - Added get_metadata_batch() -- one round-trip for AdvancedFeatures/
26
+ CalibrationInfo/hardware/firmware/channels/error state/time,
27
+ confirmed 6-14x faster than individual reads over relay.
28
+ - Wired set_advanced_features() into cli.py -- one flag per field
29
+ (--set-local-control-enabled/--set-auto-dim-timeout/
30
+ --set-max-fan-speed/--set-fan-shutdown-enabled).
31
+ - Added set_advanced_features() -- writes LocalControlEnabled/
32
+ AutoDimTimeout/MaxFanSpeed/FanShutdownEnabled, whichever are given.
33
+ - get_current_light_intensities() now uses get_app_lunar_date(),
34
+ deliberately replicating a confirmed app bug in its lunar
35
+ calculation instead of the physically correct date.
36
+ - Fixed RelayedMobiusDevice missing overrides for
37
+ get_supported_attributes()/get_attributes_batch() -- both bypassed
38
+ the relay path entirely.
39
+ - Fixed --dump-attributes/--set-time-to-now in cli.py ignoring
40
+ --relay-target.
41
+ - Added get_attributes_batch() -- reads multiple attributes in one
42
+ round-trip.
43
+ - Added get_supported_attributes()/dump_attributes()/
44
+ enrich_attribute_dump(), replicating the app's own attribute-dump
45
+ feature. New CLI flags: --dump-attributes/--parse-dump.
46
+
3
47
  ## 0.5.0
4
48
 
5
49
  - get_pump_telemetry() now returns gph_reliable/minimum_gph/maximum_gph
@@ -333,8 +377,8 @@ branch has been merged into main as part of this release.
333
377
  is a bare, meaningless ack the app itself discards, confirmed
334
378
  byte-for-byte via real hardware testing showing exactly 1 byte in every
335
379
  relayed read's outer response), matched purely by CoAP token
336
- (confirmed via reverse engineering: `coapRequest.getToken() ==
337
- coapResponse.getToken()`), never by the outer FSCI message ID. Added
380
+ (confirmed via reverse engineering: the request's own token must
381
+ match the response's own token), never by the outer FSCI message ID. Added
338
382
  `MobiusDevice._register_coap_token()`/`wait_for_coap_response()` -- a
339
383
  token-keyed pending-response registry, entirely separate from the
340
384
  existing message-ID-based `self._pending` mechanism -- wired into
@@ -452,7 +496,8 @@ branch has been merged into main as part of this release.
452
496
  (`get_device_summary()`, `get_current_light_intensities()`, etc.) works
453
497
  transparently through relay, confirmed with a test that specifically
454
498
  exercises an inherited high-level method rather than just the
455
- overridden primitives. Confirmed `request.getData(true)` builds a
499
+ overridden primitives. Confirmed via reverse engineering the app that its
500
+ own request wrapping builds a
456
501
  complete self-contained FSCI frame (byte-for-byte identical in
457
502
  structure to this library's own `build_frame()`), resolving an
458
503
  ambiguity flagged in an earlier commit -- relaying a request needed no
@@ -564,10 +609,11 @@ branch has been merged into main as part of this release.
564
609
 
565
610
  - **Major fix: device identity is now serial-number-based, not BLE-address-based.**
566
611
  Real-world testing surfaced a device whose advertised MAC address changed
567
- between runs. Investigating the decompiled app's own `Peripheral` class
612
+ between runs. Investigating the app via reverse engineering
568
613
  confirmed the official app never relies on address stability at all --
569
- `Peripheral.equals()`/`hashCode()` are based purely on serial number, and
570
- `Peripheral.update()` explicitly replaces the stored BLE address whenever
614
+ its own equality and hash logic for a discovered device are based purely
615
+ on serial number, and
616
+ its own update logic explicitly replaces the stored BLE address whenever
571
617
  a new scan matches an existing device by serial. This library previously
572
618
  assumed address stability throughout.
573
619
  - New `find_device_by_serial()` and `dedupe_by_serial()`
@@ -644,8 +690,8 @@ branch has been merged into main as part of this release.
644
690
  0-100+ percentages instead of raw permille, flagging boosted channels via
645
691
  `.diagnostics["hyperdrive_channels"]`. Confirmed byte-identical to the
646
692
  old `raw/10` shortcut for the common (non-boosted) case; only changes
647
- results for genuinely boosted channels. `LightPowerInfo`'s Prime/Hydra-
648
- specific `gm8b4()` wattage override table is not replicated -- see
693
+ results for genuinely boosted channels. A confirmed Prime/Hydra-
694
+ specific wattage override table is not replicated -- see
649
695
  `documentation/06-light-schedule.md`.
650
696
  - Also refactored `get_attribute()` into a thin wrapper around a new
651
697
  `get_attribute_raw()`, which preserves the device-reported response
@@ -715,11 +761,11 @@ branch has been merged into main as part of this release.
715
761
  Home Assistant integration on top of this library -- the hex-encoded
716
762
  serial was producing unnecessarily long/ugly entity IDs.
717
763
  - `get_pump_telemetry()` now includes a confirmed `"speed_percent"` field
718
- alongside the existing raw `"speed"`. `MotorSpeed` is confirmed (via the
719
- decompiled app's own display code) to be a percentage of max pump power
720
- in tenths of a percent, NOT RPM -- `String.format("%.0f%%",
721
- Math.abs(currentSpeed / 10.0f))` is literally what the app does to this
722
- exact value. `speed_percent` is that same `abs(speed)/10` conversion;
764
+ alongside the existing raw `"speed"`. `MotorSpeed` is confirmed (via
765
+ reverse engineering the app's own display code) to be a percentage of max pump power
766
+ in tenths of a percent, NOT RPM -- the app's own display logic formats
767
+ this same value with the exact same abs-value, tenths-to-percent
768
+ conversion. `speed_percent` is that same `abs(speed)/10` conversion;
723
769
  `speed` is still returned raw since its sign encodes rotation direction
724
770
  (reverse), which `speed_percent` discards.
725
771
  - 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.5.0
3
+ Version: 0.7.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
@@ -124,6 +124,12 @@ mobius-scan --adapter hci0
124
124
  - **Reboot a device** (`reboot()`) — a WRITE, matching the app's own
125
125
  "Restart" button exactly. Confirmed against real hardware directly
126
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).
127
133
  - **Low-level protocol access** (`build_frame`, `get_attribute`,
128
134
  `set_attribute`, ...) if you want to go beyond what's wrapped in
129
135
  `MobiusDevice`.
@@ -92,6 +92,12 @@ mobius-scan --adapter hci0
92
92
  - **Reboot a device** (`reboot()`) — a WRITE, matching the app's own
93
93
  "Restart" button exactly. Confirmed against real hardware directly
94
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).
95
101
  - **Low-level protocol access** (`build_frame`, `get_attribute`,
96
102
  `set_attribute`, ...) if you want to go beyond what's wrapped in
97
103
  `MobiusDevice`.
@@ -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
  |---|---|---|
@@ -190,32 +190,65 @@ automatically -- no separate calls needed for the common case.
190
190
  **`get_lunar_phase_info()`**: reads `LunarPhasesCurrentDay`/
191
191
  `LunarPhasesCurrentScalar` directly off the device -- its own
192
192
  firmware-computed lunar state, confirmed against real hardware to
193
- match `LUNAR_CYCLE_SCALERS[current_day]` exactly, even though the app
194
- itself never reads either attribute (it always computes the
195
- equivalent value client-side, the same way this library does). Useful
196
- as an independent cross-check against this library's own calculated
197
- value.
198
-
199
- **A confirmed bug in the app itself, not in this library**: the app's
200
- own `date`-to-calendar-day conversion
201
- (`LunarInfo.jdFromGregorianDate()`) applies the phone's own local
202
- timezone via `Calendar.getInstance()` on top of a value
203
- (`Tank.getDateTime().date()`) that's already timezone-adjusted once
204
- (the device's own `Epoch`-to-`LocalTime` offset). This double
205
- application means the app's own displayed lunar value rolls over to
206
- the next calendar day exactly one UTC-offset early, relative to the
207
- device's true local midnight (assuming the phone and device agree on
208
- timezone, the common case) -- confirmed via the exact arithmetic and
209
- via the general symptom matching (a real, reported case of the app
210
- showing the next day's value visibly before midnight, on a
211
- device/phone at UTC+1, consistent with this mechanism). The precise
212
- one-hour timing itself is a derived prediction from the arithmetic,
213
- not something separately checked against a clock. This library's own calculation (and the device's own
214
- firmware, per `get_lunar_phase_info()` above) doesn't have this bug --
215
- both correctly roll over at true local midnight. A mismatch between
216
- this library's own calculated lunar value and the app's own displayed
217
- one, specifically around midnight, is expected and doesn't indicate
218
- anything wrong with this library.
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.
219
252
 
220
253
  ## Insolation detection (flagged, not implemented)
221
254
 
@@ -242,22 +275,31 @@ from the manually-programmed one on a given day.
242
275
 
243
276
  `.diagnostics` also includes `"is_night_segment"`, `"lunar_enabled"`,
244
277
  `"scalar_source"` (one of `"lunar"`/`"night_no_lunar"`/
245
- `"schedule_intensity"`), and `"scalar"` (the actual final multiplier
246
- applied) -- useful for telling apart which branch/scalar produced a given
247
- value, e.g. when a computed value doesn't match the app's own displayed
248
- one and it isn't obvious whether `is_night_segment()` misjudged a
249
- 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:
250
289
 
251
290
  ```python
252
291
  result = await device.get_current_light_intensities()
253
292
  print(result.diagnostics)
254
293
  # {'insolation_active': False, 'is_night_segment': True,
255
- # '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'}
256
296
  ```
257
297
 
258
298
  `lunar_enabled` is `None` (not `False`) when `is_night_segment` is
259
299
  `False`, since it isn't checked/relevant outside the night segment --
260
- 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"`.
261
303
 
262
304
  ## Hyperdrive (boosted channels above 100%)
263
305
 
@@ -271,7 +313,7 @@ how much headroom that specific channel actually has.
271
313
  `get_current_light_percentages()` accounts for this: it fetches
272
314
  `MaxPower`/`NormalPower` (`get_channel_power_info()`, attributes `1504`/
273
315
  `1513`) and applies `channel_percent_value()` (`mobius.power`, ported from
274
- `LightPowerInfo.getPercentValue()`) per channel. For the common case --
316
+ the app's own confirmed equivalent logic) per channel. For the common case --
275
317
  non-hyperdrive channels/devices, i.e. any raw value at or below 1000 --
276
318
  this gives the same results as a simple `raw/10`; the extra machinery
277
319
  only changes anything for genuinely boosted channels. It also flags which
@@ -282,22 +324,39 @@ alone (two more `Get`s for `MaxPower`/`NormalPower`). Use
282
324
  `get_current_light_intensities()` directly if hyperdrive accuracy isn't
283
325
  needed, or to minimize round trips.
284
326
 
285
- **Not replicated**: `LightPowerInfo`'s `gm8b4()` lookup table -- a
327
+ **Not replicated**: a lookup table confirmed present in the app -- a
286
328
  hardcoded wattage override for a handful of specific AquaIllumination
287
329
  Prime/Hydra model + channel combinations. Only relevant to those specific
288
330
  models; the general case (including every EcoTech Radion model this
289
331
  library has been verified against) doesn't use it.
290
332
 
291
- ## 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.
292
341
 
293
342
  - "Insolation" -- a real per-calendar-day natural daylight simulation:
294
- each light stores a 365-entry table (one per day of the year, packed
295
- into 8 bytes via `InsolationTable1`/`InsolationTable2`), where every
296
- entry holds sunrise time, sunset time, sun intensity (0-100%), moonrise
297
- time, moonset time, and moon intensity (0-100%) for that specific
298
- 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
299
356
  variation instead of running one fixed schedule year-round. Gated
300
- behind `AccountSettings.hasPermission()` in the app -- likely a
301
- paid/premium feature, not merely an obscure one. Not implemented here;
302
- 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.
303
362
  - Writing/programming a schedule (only reading is implemented).
@@ -25,15 +25,17 @@ care which company ID a payload came from.
25
25
 
26
26
  ## Payload format
27
27
 
28
- `bleak` strips the 2-byte company-ID prefix that the original Java parser
29
- (`Peripheral.parseManufactureData`) expects as part of its input — so this
28
+ `bleak` strips the 2-byte company-ID prefix that the app's own equivalent
29
+ parser expects as part of its input — so this
30
30
  library's `parse_manufacturer_data()` takes the already-stripped 23-byte
31
- `bleak` payload directly, which corresponds to `javaBArr[2:]`.
31
+ `bleak` payload directly, which corresponds to the app's own byte array
32
+ starting at its own index 2.
32
33
 
33
- The decompile has several format branches keyed by total length and a
34
+ The app has several format branches keyed by total length and a
34
35
  "version" byte (the company ID's own low byte -- see "Company ID" above);
35
36
  **only the branch actually observed on real hardware is implemented**
36
- (23-byte `bleak` payload / 25-byte original Java `bArr` length) --
37
+ (23-byte `bleak` payload / 25-byte length in the app's own byte-array
38
+ numbering) --
37
39
  confirmed identical for both company IDs' own "version 1"/"version 2"
38
40
  variant of this specific length. Payloads of any other length return
39
41
  `None` rather than guessing at an unimplemented branch.
@@ -79,11 +81,12 @@ payload, i.e. what `parse_manufacturer_data()` actually receives.)
79
81
 
80
82
  ## "Flags" — deliberately not exposed
81
83
 
82
- `javaBArr[1]` is the company ID's own *high byte* for EcoTech Marine
84
+ The byte right after the company ID is the company ID's own *high byte* -- for EcoTech Marine
83
85
  (`0x0202`) that's a constant `0x02`; for AquaIllumination (`0x0001`)
84
- it's a constant `0x00`. The decompile still gives this byte its own,
85
- different treatment per company ID: the EcoTech branch just wraps it
86
- in an opaque `Flags` object with no further use shown; the
86
+ it's a constant `0x00`. The app gives this byte its own,
87
+ different treatment per company ID, confirmed via reverse engineering it: the
88
+ EcoTech branch just wraps it
89
+ in an opaque `Flags`-style value with no further use shown; the
87
90
  AquaIllumination branch reads it as `pendingNotifications = byte > 0`
88
91
  (always `False`, since the byte is a constant `0x00`). Either way,
89
92
  it's not independent payload data — decoding "flags" here would just
@@ -50,19 +50,21 @@ alongside the existing Get(`0x17`)/Set(`0x18`) attribute opcodes:
50
50
  response actually arrives, as a separate, asynchronous message
51
51
  ("indication"). Matched to its originating request purely by CoAP
52
52
  token, confirmed via reverse engineering the app's own exact
53
- comparison:
54
- `coapRequest.getToken() == coapResponse.getToken()`. The outer FSCI
53
+ comparison: the
54
+ request's own token must match the response's own token. The outer FSCI
55
55
  message ID is never used for this correlation -- it only has meaning
56
56
  for the single BLE hop to the gateway, not across a full Thread mesh
57
57
  round-trip.
58
58
 
59
- **Request** (`encode_coap_request()`) -- `CoapRequest`'s constructor:
59
+ **Request** (`encode_coap_request()`) -- confirmed via reverse
60
+ engineering the app's own equivalent request-construction logic:
60
61
  ```
61
62
  IPv6(16 bytes, REVERSED) + token(4, LE) + method(2, LE)
62
63
  + payload_length(2, LE) + request_type(1) + payload
63
64
  ```
64
65
 
65
- **Response** (`decode_coap_response()`) -- `CoapResponse`'s constructor:
66
+ **Response** (`decode_coap_response()`) -- confirmed via reverse
67
+ engineering the app's own equivalent response-construction logic:
66
68
  ```
67
69
  IPv6(16 bytes, NOT reversed) + token(4, LE) + response_code(2, LE)
68
70
  + payload_length(2, LE) + request_type(1) + payload
@@ -92,6 +92,28 @@ included as a starting point.
92
92
  matching it against known devices in a scan result is left to the
93
93
  caller.
94
94
 
95
+ ## Deferred: broader use of batched attribute reads
96
+
97
+ `get_attributes_batch()` (see
98
+ [14-batched-attribute-reads.md](./14-batched-attribute-reads.md)) reads
99
+ multiple, different attributes in one round-trip — confirmed as a real
100
+ capability, currently used only by `dump_attributes()`. `get_device_summary()`
101
+ makes ~15-20 sequential single-attribute-ish sub-calls of its own, several
102
+ of which are simple, few-attribute reads that could be consolidated the
103
+ same way; `ha-mobius`'s own `coordinator.py` has a separate, independent
104
+ ~9-10 call sequence per poll that would need its own, similarly-scoped
105
+ pass (it doesn't call `get_device_summary()` at all, so optimizing that
106
+ method alone wouldn't help it).
107
+
108
+ Deliberately deferred rather than attempted as one sweeping change: many
109
+ of those sub-calls aren't simple attribute reads at all (schedule
110
+ reading, model/primitive-dependent telemetry dispatch), and rewriting
111
+ them to fit a shared batch risks regressing already-confirmed, working
112
+ behavior for a broad efficiency gain that hasn't been scoped call-by-call
113
+ yet. Left for a future pass — planned before a 1.0.0 release, not
114
+ abandoned — starting with identifying which specific sub-calls in each
115
+ repo are safely batchable vs. not, rather than assuming all of them are.
116
+
95
117
  ## Things that are correctness-affecting gotchas, not gaps
96
118
 
97
119
  - **`IntEnum` zero-value truthiness.** `ErrorState.NoError` and
@@ -233,10 +233,10 @@ interpreted using its own block's starting index (never assuming one
233
233
  single starting index covers every returned value).
234
234
 
235
235
  **Firmware version display labels are fully confirmed against real
236
- hardware.** `FirmwareType.name(Model)` in the decompiled app maps each
236
+ hardware.** The app's own confirmed firmware-label lookup maps each
237
237
  `FirmwareType` value to the exact label shown in the UI, gated by
238
- manufacturer. For EcoTech Marine devices specifically (the `isEtm()`
239
- branch), confirmed to match real output exactly:
238
+ manufacturer. For EcoTech Marine devices specifically,
239
+ confirmed to match real output exactly:
240
240
 
241
241
  | `FirmwareType` | Value | Label |
242
242
  |---|---|---|
@@ -11,17 +11,11 @@ identity, never on address stability: its own equality and hash logic
11
11
  for a discovered device compares serial numbers only, and its own
12
12
  "update" logic for a re-seen device explicitly replaces the stored BLE
13
13
  address with whatever the device is currently advertising, rather than
14
- treating a changed address as a different device.
15
-
16
- `equals()`/`hashCode()` never reference the BLE address at all -- only
17
- `serialNumber`. `update()`, called whenever a new scan result matches an
18
- *existing* `Peripheral` by serial number, explicitly **overwrites** the
19
- stored `BluetoothDevice` (which encapsulates the address) with whatever
20
- came from the new scan result. This is the app's designed architecture
21
- for handling devices whose BLE address may change over time (plausibly
22
- BLE resolvable-private-address rotation, though this library doesn't
23
- need to know the mechanism, only that the address isn't a safe long-term
24
- identifier).
14
+ treating a changed address as a different device. This is the app's
15
+ designed architecture for handling devices whose BLE address may change
16
+ over time (plausibly BLE resolvable-private-address rotation, though
17
+ this library doesn't need to know the mechanism, only that the address
18
+ isn't a safe long-term identifier).
25
19
 
26
20
  ## API
27
21