python-mobius 0.2.0__tar.gz → 0.3.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 (66) hide show
  1. python_mobius-0.3.0/.forgejo/scripts/extract_changelog_section.py +42 -0
  2. python_mobius-0.3.0/.forgejo/workflows/release.yml +110 -0
  3. {python_mobius-0.2.0 → python_mobius-0.3.0}/CHANGELOG.md +79 -0
  4. {python_mobius-0.2.0 → python_mobius-0.3.0}/PKG-INFO +1 -1
  5. {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/00-overview.md +1 -1
  6. {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/06-light-schedule.md +65 -68
  7. python_mobius-0.3.0/documentation/09-thread-coap-relay.md +214 -0
  8. {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/10-known-gaps-and-open-questions.md +0 -11
  9. {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/11-device-status-attributes.md +64 -27
  10. python_mobius-0.3.0/documentation/12-device-identity-and-address-stability.md +105 -0
  11. {python_mobius-0.2.0 → python_mobius-0.3.0}/pyproject.toml +1 -1
  12. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/__init__.py +5 -1
  13. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/constants.py +172 -5
  14. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/device.py +155 -69
  15. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/modifiers.py +22 -8
  16. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/relay.py +38 -8
  17. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_device_summary.py +22 -11
  18. python_mobius-0.3.0/tests/test_firmware_versions.py +298 -0
  19. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_hyperdrive_device.py +20 -20
  20. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_light_intensity_branching.py +17 -13
  21. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_modifiers.py +40 -4
  22. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_relay.py +75 -0
  23. python_mobius-0.2.0/documentation/09-thread-coap-relay.md +0 -337
  24. python_mobius-0.2.0/documentation/12-device-identity-and-address-stability.md +0 -114
  25. python_mobius-0.2.0/tests/test_firmware_versions.py +0 -141
  26. {python_mobius-0.2.0 → python_mobius-0.3.0}/.gitignore +0 -0
  27. {python_mobius-0.2.0 → python_mobius-0.3.0}/LICENSE +0 -0
  28. {python_mobius-0.2.0 → python_mobius-0.3.0}/README.md +0 -0
  29. {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/01-ble-transport.md +0 -0
  30. {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/02-framing-and-crc.md +0 -0
  31. {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/03-attributes-and-opcodes.md +0 -0
  32. {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/04-device-identity.md +0 -0
  33. {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/05-scenes.md +0 -0
  34. {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/07-pump-schedule.md +0 -0
  35. {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/08-manufacturer-data.md +0 -0
  36. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/cli.py +0 -0
  37. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/coap.py +0 -0
  38. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/crc.py +0 -0
  39. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/device_status.py +0 -0
  40. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/discovery.py +0 -0
  41. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/frame.py +0 -0
  42. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/manufacturer.py +0 -0
  43. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/mesh_address.py +0 -0
  44. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/power.py +0 -0
  45. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/pump_status.py +0 -0
  46. {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/schedule.py +0 -0
  47. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_coap.py +0 -0
  48. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_coap_indication_handling.py +0 -0
  49. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_connection_cleanup.py +0 -0
  50. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_device_status.py +0 -0
  51. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_device_time.py +0 -0
  52. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_frame.py +0 -0
  53. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_frame_sniffer.py +0 -0
  54. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_insolation_detection.py +0 -0
  55. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_light_schedule.py +0 -0
  56. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_manufacturer.py +0 -0
  57. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_manufacturer_lookup.py +0 -0
  58. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_mesh_address.py +0 -0
  59. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_mesh_discovery.py +0 -0
  60. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_mesh_discovery_direct_connect.py +0 -0
  61. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_power.py +0 -0
  62. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_pump_schedule.py +0 -0
  63. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_pump_status.py +0 -0
  64. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_schedule_intensity_scalar.py +0 -0
  65. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_serial_decoding.py +0 -0
  66. {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_serial_identity.py +0 -0
@@ -0,0 +1,42 @@
1
+ """
2
+ Extracts the section of CHANGELOG.md under the heading matching a given
3
+ version (e.g. version "0.3.0" -> the content under "## 0.3.0", up to the
4
+ next "## " heading or end of file), and writes it to release_notes.md.
5
+
6
+ Used by .forgejo/workflows/release.yml to source Forgejo release notes
7
+ from CHANGELOG.md directly, rather than letting Forgejo auto-generate
8
+ notes from commits. Takes the tag name (e.g. "v0.3.0") as its one
9
+ argument and strips the leading "v" itself, since tags use that prefix
10
+ but CHANGELOG.md's own headings don't.
11
+
12
+ Exits with a clear error (nonzero exit code) if no matching section is
13
+ found, rather than silently producing an empty or wrong release.
14
+ """
15
+
16
+ import re
17
+ import sys
18
+
19
+
20
+ def main() -> None:
21
+ if len(sys.argv) != 2:
22
+ raise SystemExit(f"usage: {sys.argv[0]} <tag-name>")
23
+
24
+ version = sys.argv[1].lstrip("v")
25
+ text = open("CHANGELOG.md", encoding="utf-8").read()
26
+ pattern = re.compile(
27
+ r"^## " + re.escape(version) + r"\s*$\n(.*?)(?=^## |\Z)",
28
+ re.MULTILINE | re.DOTALL,
29
+ )
30
+ match = pattern.search(text)
31
+ if not match:
32
+ raise SystemExit(
33
+ f"No CHANGELOG.md section found for version {version!r} "
34
+ f"(looked for a line starting with '## {version}')"
35
+ )
36
+
37
+ with open("release_notes.md", "w", encoding="utf-8") as f:
38
+ f.write(match.group(1).strip() + "\n")
39
+
40
+
41
+ if __name__ == "__main__":
42
+ main()
@@ -0,0 +1,110 @@
1
+ name: Release
2
+
3
+ # Triggers on any tag matching v* (e.g. v0.3.0) -- confirmed to match every
4
+ # tag this project has used so far -- or manually via workflow_dispatch
5
+ # (Forgejo's web UI / API "Run workflow" button) for testing/debugging the
6
+ # workflow itself. Does NOT trigger on regular pushes.
7
+ # "on" is quoted deliberately -- unquoted, some YAML parsers (YAML 1.1)
8
+ # interpret the bare word as the boolean `true` instead of the literal
9
+ # key GitHub/Forgejo Actions expects.
10
+ "on":
11
+ push:
12
+ tags:
13
+ - "v*"
14
+ workflow_dispatch: {}
15
+
16
+ jobs:
17
+ release:
18
+ # ubuntu-22.04 label -> catthehacker/ubuntu:act-22.04, the only one of
19
+ # this runner's three labels with a full, GitHub-Actions-compatible
20
+ # environment (Python, Node, glibc). The "docker" label maps to a bare
21
+ # Alpine image, which lacks the glibc most published actions expect.
22
+ runs-on: ubuntu-22.04
23
+ steps:
24
+ # NOT using actions/checkout here -- it's broken for SHA256 repos
25
+ # (this one is): it initializes the local git repo before fetching
26
+ # without specifying --object-format=sha256, so the real 64-char
27
+ # SHA256 ref it then tries to fetch gets rejected by the freshly
28
+ # (SHA1-defaulting) initialized local repo, failing with "couldn't
29
+ # find remote ref". Confirmed via real testing on this exact
30
+ # workflow, and a known, still-open, ~1-year-old bug, tracked both
31
+ # upstream (actions/checkout#1843) and in Forgejo itself
32
+ # (forgejo/forgejo#9431) -- not something specific to this workflow.
33
+ # A plain `git clone` doesn't have this problem: cloning negotiates
34
+ # the object format with the remote from the start, rather than
35
+ # assuming SHA1 and fetching into it after the fact. Auth uses the
36
+ # same http.extraheader bearer-token approach actions/checkout
37
+ # itself uses internally (confirmed working in the earlier failed
38
+ # run -- "Setting up auth" succeeded; only the later fetch failed).
39
+ - name: Checkout
40
+ env:
41
+ FORGEJO_TOKEN: ${{ secrets.GITHUB_TOKEN }}
42
+ run: |
43
+ git -c http.extraheader="Authorization: Bearer ${FORGEJO_TOKEN}" \
44
+ clone --depth 1 --branch "${{ github.ref_name }}" \
45
+ https://code.r3pek.org/r3pek/python-mobius.git .
46
+
47
+ - name: Set up Python
48
+ uses: https://code.forgejo.org/actions/setup-python@v5
49
+ with:
50
+ python-version: "3.12"
51
+
52
+ - name: Install build and test dependencies
53
+ run: |
54
+ python -m pip install --upgrade pip
55
+ pip install -e ".[dev]"
56
+ pip install build twine
57
+
58
+ # Gate: the rest of this job (build, PyPI publish, release creation)
59
+ # only runs if this step succeeds.
60
+ - name: Run tests
61
+ run: pytest -q
62
+
63
+ - name: Build package
64
+ run: python -m build
65
+
66
+ - name: Validate built package
67
+ run: python -m twine check dist/*
68
+
69
+ - name: Publish to PyPI
70
+ # Only on a real tag push -- a workflow_dispatch run (manual, for
71
+ # testing/debugging this workflow itself) has no version tag to
72
+ # publish under and shouldn't attempt to.
73
+ if: github.event_name == 'push'
74
+ env:
75
+ TWINE_USERNAME: __token__
76
+ TWINE_PASSWORD: ${{ secrets.PYPI_API_TOKEN }}
77
+ run: python -m twine upload dist/*
78
+
79
+ # Pulls the section of CHANGELOG.md under the heading matching this
80
+ # tag's version (tag v0.3.0 -> "## 0.3.0") for the Forgejo release
81
+ # notes, rather than letting Forgejo auto-generate notes from commits.
82
+ # Fails the job (on purpose) if no matching section is found, rather
83
+ # than silently publishing a release with empty/wrong notes.
84
+ # Same event-gating reason as the PyPI step -- github.ref_name on a
85
+ # manual run is whatever branch was picked, not a version, so there's
86
+ # no matching CHANGELOG.md section to find at all.
87
+ - name: Extract changelog section for this version
88
+ if: github.event_name == 'push'
89
+ run: python3 .forgejo/scripts/extract_changelog_section.py "${{ github.ref_name }}"
90
+
91
+ # direction: upload with no release-dir set -- no wheel/sdist attached
92
+ # to the Forgejo release itself (PyPI is the source of truth for the
93
+ # built artifacts); this step only creates the release entry with the
94
+ # extracted changelog section as its notes. Worth confirming on the
95
+ # first real run that omitting release-dir doesn't pick up dist/ by
96
+ # some other default -- the action's own docs only show examples with
97
+ # release-dir explicitly set, so this specific "notes only" case
98
+ # isn't directly demonstrated there.
99
+ # Same event-gating reason as the two steps above -- nothing to
100
+ # release against on a manual, non-tag run.
101
+ - name: Create Forgejo release
102
+ if: github.event_name == 'push'
103
+ uses: https://code.forgejo.org/actions/forgejo-release@v2
104
+ with:
105
+ direction: upload
106
+ url: https://code.r3pek.org
107
+ repo: r3pek/python-mobius
108
+ token: ${{ secrets.GITHUB_TOKEN }}
109
+ tag: ${{ github.ref_name }}
110
+ release-notes-file: release_notes.md
@@ -1,5 +1,84 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.3.0
4
+
5
+ - **Fixed a real, high-impact bug in `is_night_segment()`: the condition
6
+ deciding whether to apply lunar-phase reduction had its first check
7
+ inverted**, confirmed directly from raw smali bytecode after JADX's
8
+ own decompiled Java carried a "Code duplicated" warning on this exact
9
+ method that turned out to be a real signal -- the decompiler had
10
+ produced `!point.has(NIGHT)` where the actual bytecode requires
11
+ `point.has(NIGHT)`. In practice this meant lunar reduction was applied
12
+ only during the single dusk-transition segment (and only if the query
13
+ time exactly matched a night-flagged point), while every other
14
+ night-to-night segment -- the bulk of an actual night -- silently used
15
+ the schedule-intensity scalar instead. Found via real hardware
16
+ testing: a light in a `23:00[NIGHT,SUNSET] -> 23:27[NIGHT]` segment
17
+ produced a value consistent with schedule-intensity-only scaling from
18
+ this library, while the real app displayed a noticeably lower,
19
+ lunar-consistent value at the same moment -- mathematically impossible
20
+ to explain by schedule-intensity alone within that segment's raw-value
21
+ range. 4 tests fixed/rewritten to match the confirmed correct
22
+ semantics (including one reproducing the exact real-world schedule and
23
+ moment this was found from), plus 2 more updated in
24
+ `test_light_intensity_branching.py` whose fixture schedule needed to
25
+ actually be a night-to-night segment rather than the (no longer
26
+ qualifying) dusk-transition segment it used before.
27
+
28
+ - **Fixed a real bug, the same class of gap this project has hit before:
29
+ `get_hardware_info()` was never wired into `get_device_summary()`,**
30
+ meaning it worked correctly in isolation (with its own passing tests)
31
+ but was silently missing from the CLI's output and any other "give me
32
+ everything" caller built on the summary entrypoint -- see
33
+ `get_device_summary()`'s own "BUG NOTE" docstring, which describes the
34
+ exact same thing having happened to `get_group_info()`/
35
+ `get_calibration_info()`/etc. previously. Added `info["hardware_info"]
36
+ = await self.get_hardware_info()` alongside the existing
37
+ `firmware_versions` line. The CLI needed no changes at all -- its
38
+ summary printing is a generic loop over whatever `get_device_summary()`
39
+ returns, so this alone was the actual fix. 2 tests extended (not just
40
+ `"key" in summary`, but the actual decoded value) specifically so this
41
+ field can't silently regress the way it silently went missing.
42
+
43
+ - **`get_hardware_info()` now decodes `Color`/`ProductType`/`RadioType`/
44
+ `MotorType` into confirmed display labels**, instead of returning raw
45
+ bytes. Each is itself a confirmed enum with confirmed labels
46
+ (`M.Color`/`M.ProductType`/`M.RadioType`/`M.MotorType` in the
47
+ decompile) -- added `Color`, `RadioType`, `MotorType`, `ProductType`
48
+ enums plus their `*_LABELS` dicts to `mobius.constants`. An
49
+ unrecognized value falls back to `"Unknown (N)"` rather than raising.
50
+ `Revision`/`Segments` still have no confirmed enum meaning -- returned
51
+ as plain integers (previously raw bytes) rather than formatted.
52
+ Several `MotorType`/`ProductType` labels were base64-string-obfuscated
53
+ in the decompile rather than plain literals (apparently to avoid
54
+ casual discovery of unannounced product names in a simple APK string
55
+ dump) -- decoded and included regardless, since it's plain base64, not
56
+ any actual protection. 3 new tests. **Breaking change** for any caller
57
+ relying on `get_hardware_info()`'s previous raw-bytes return shape.
58
+
59
+ ## 0.2.1
60
+
61
+ - **Fixed a real bug found via real hardware testing: some "get all
62
+ elements" responses were silently truncated to their first block.**
63
+ `get_attribute_raw()` returned only the first matching
64
+ `(attrId, index, count, values)` block in a response and discarded any
65
+ others -- a Radion light showing 8 firmware components in the official
66
+ app came back as only 5 via `get_firmware_versions()`, because that
67
+ light's `FirmwareVersion` response genuinely splits across two
68
+ separate blocks (one covering `MainMicroOS`/`MainMicroBootloader`,
69
+ another covering the `QCA4020`-prefixed types), not one block covering
70
+ every element. Added `get_attribute_raw_all()`/`get_attribute_all()`
71
+ (`MobiusDevice`, with a matching `RelayedMobiusDevice` override --
72
+ without one, callers built on these would have silently fallen through
73
+ to `MobiusDevice`'s own non-relay implementation when called on a
74
+ relayed device) -- return every matching block instead of just the
75
+ first. `get_firmware_versions()`, `get_hardware_info()`,
76
+ `get_channel_power_info()`, and `get_supported_channels()` are now
77
+ built on these rather than the single-block versions, merging every
78
+ block's values, each interpreted using its own block's starting index.
79
+ 5 new tests, including ones that specifically reproduce the two-block
80
+ scenario found on real hardware, both directly and through relay.
81
+
3
82
  ## 0.2.0
4
83
 
5
84
  Thread/CoAP device relay -- see
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: python-mobius
3
- Version: 0.2.0
3
+ Version: 0.3.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,7 +49,7 @@ Interface conventions, extended by EcoTech).
49
49
  | [06-light-schedule.md](./06-light-schedule.md) | Light point-schedule format + client-side interpolation |
50
50
  | [07-pump-schedule.md](./07-pump-schedule.md) | Pump primitive format, mode/parameter tables, block lookup |
51
51
  | [08-manufacturer-data.md](./08-manufacturer-data.md) | BLE advertisement format (no-connection discovery) |
52
- | [09-thread-coap-relay.md](./09-thread-coap-relay.md) | How the official app relays to devices without direct BLE (not implemented here) |
52
+ | [09-thread-coap-relay.md](./09-thread-coap-relay.md) | Relaying requests to devices without a direct BLE connection, through another connected device |
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 |
@@ -8,18 +8,12 @@ There's no attribute you can `Get` that tells you what a light is currently
8
8
  displaying. Instead, the app fetches the entire programmed point-schedule
9
9
  and computes the current intensity **client-side**, by linearly
10
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.
11
+ applying a schedule-level master intensity scalar (see below), plus
12
+ lunar-cycle and acclimation-ramp modifiers under specific conditions.
14
13
 
15
14
  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.
15
+ client-side computation, including the master intensity scalar and the
16
+ lunar/acclimation modifiers.
23
17
 
24
18
  ## Schedule-level master intensity (`Schedule1Intensity`, attribute 511)
25
19
 
@@ -34,13 +28,13 @@ regular per-channel value some models expose, not this).
34
28
  `get_schedule_intensity()` fetches this (a plain `Get` on
35
29
  `Schedule1Intensity`/`Schedule2Intensity`, no special indexing -- a short,
36
30
  0-1000, default 500/50% per the app's own initialization code).
37
- `get_current_light_intensities()` now applies it automatically.
31
+ `get_current_light_intensities()` applies it automatically.
38
32
 
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.
33
+ Lunar-phase and acclimation-ramp reductions layer on top of this same
34
+ scalar under specific conditions (night + lunar phases enabled; an
35
+ active, incomplete acclimation period) -- see "Lunar phase and
36
+ acclimation ramp" below. "Insolation" (a separate, rarer modifier) is
37
+ not implemented.
44
38
 
45
39
  ## Wire format
46
40
 
@@ -76,10 +70,8 @@ Repeating triplets, one per channel the point defines:
76
70
  VisualID (u8) + intensity (u16 LE, 0-1000 = 0.0-100.0%)
77
71
  ```
78
72
 
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).
73
+ Confirmed against a full 9-point schedule fetched live from real XR15
74
+ lights.
83
75
 
84
76
  ## Interpolation algorithm
85
77
 
@@ -94,45 +86,60 @@ Ported from `PointSchedule.getIntensitiesAtTime()`:
94
86
  4. Channels absent from a point default to `0` (off).
95
87
 
96
88
  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).
89
+ midpoint blending, and midnight-wraparound blending -- see
90
+ `tests/test_light_schedule.py`) and against a real 9-point schedule
91
+ fetched live, which produces a coherent daily light-intensity curve (dim
92
+ moonlight overnight → ramp to full blues/UV midday → wind back down).
101
93
 
102
94
  ## `SupportedColorChannels` (attribute 901)
103
95
 
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`).
96
+ A static list of `VisualID` values (channel identities) a given light has
97
+ -- this is capability metadata, not live state. Confirmed via `Get` with
98
+ `index=0, count=0xFFFF` against real hardware.
110
99
 
111
100
  ## Lunar phase and acclimation ramp
112
101
 
113
- Both now implemented, on top of the schedule-level master intensity above.
102
+ Both implemented, on top of the schedule-level master intensity above.
114
103
  Ported from `LunarInfo.java`/`AcclimationInfo.java` in the decompiled app.
115
104
 
116
105
  **Lunar phase** (`lunar_percent_reduction(date)`): a simplified moon-phase
117
106
  model -- a Julian-Day-based calculation (`julian_day_from_date()`, the
118
107
  standard Fliegel-Van Flandern conversion) feeding a 16-entry lookup table
119
108
  (`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.
109
+ full moon. Verified against independently-confirmed real 2026 moon phase
110
+ dates (full moon Jan 3 2026, new moon Jan 18 2026) -- the ported
111
+ algorithm matches exactly.
123
112
 
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.
113
+ This replaces the normal schedule-intensity scalar for any segment
114
+ where `is_night_segment()` is true (see below), and only when
115
+ `LunarPhasesEnabled` (attribute 907) is set on the device -- otherwise
116
+ that segment just uses a flat `1.0` (full/unreduced) scalar, matching
117
+ the app's own fallback.
129
118
 
130
119
  **`is_night_segment()`**: ported from the boolean half of
131
120
  `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.
121
+ simply "is it night" as a general concept -- it's specifically true for
122
+ the segment whose **start** point IS flagged `NIGHT` (but not also
123
+ `SUNRISE`) and whose end point is ALSO flagged `NIGHT` -- i.e. once
124
+ already into the night portion of the schedule, through to the sunrise
125
+ transition -- or when the query time exactly matches a `NIGHT`-flagged
126
+ point. In practice this covers essentially the whole night, *except*
127
+ the two transition segments themselves: dusk (start point not yet
128
+ night-flagged) and the sunrise segment (start point flagged `SUNRISE`).
129
+
130
+ **Confirmed directly from raw smali bytecode, not the decompiled Java.**
131
+ JADX's decompiler carried its own "Code duplicated" warning on this
132
+ exact method -- a real signal, not noise: its decompiled Java had this
133
+ condition's first check inverted (`!point.has(NIGHT)` instead of the
134
+ actual `point.has(NIGHT)`), which an earlier version of this port
135
+ carried forward uncorrected. Found via real hardware testing: a light
136
+ in a `23:00[NIGHT,SUNSET] -> 23:27[NIGHT]` segment showed a value
137
+ consistent with the schedule-intensity scalar from this library, while
138
+ the real app displayed a noticeably lower, lunar-consistent value at
139
+ the same moment -- mathematically impossible to explain by
140
+ schedule-intensity alone within that segment's raw-value range, which
141
+ is what prompted re-examining the raw bytecode instead of trusting the
142
+ decompiled Java a second time.
136
143
 
137
144
  **Acclimation ramp** (`AcclimationInfo`): a linear intensity ramp from
138
145
  `start_intensity` (0-1000 permille) up to full (1000) over `period_days`,
@@ -147,8 +154,8 @@ automatically -- no separate calls needed for the common case.
147
154
  ## Insolation detection (flagged, not implemented)
148
155
 
149
156
  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
157
+ `get_current_light_intensities()` cheaply detects whether it's active
158
+ and flags it, via `LightIntensityResult` -- the return value is a real
152
159
  dict (fully backward compatible with code that indexes/iterates it like a
153
160
  plain dict), with an added `.diagnostics` attribute:
154
161
 
@@ -165,18 +172,15 @@ wrong-but-present value is often more useful than nothing, and the
165
172
  severity depends on how different the real Insolation-derived schedule is
166
173
  from the manually-programmed one on a given day.
167
174
 
168
- ## Debugging diagnostics: which branch/scalar was actually used
175
+ ## Diagnostics: which branch/scalar was actually used
169
176
 
170
177
  `.diagnostics` also includes `"is_night_segment"`, `"lunar_enabled"`,
171
178
  `"scalar_source"` (one of `"lunar"`/`"night_no_lunar"`/
172
179
  `"schedule_intensity"`), and `"scalar"` (the actual final multiplier
173
- applied). Added specifically because a mismatch between this library's
174
- computed value and the app's own displayed value could stem from either
175
- `is_night_segment()` misjudging a specific schedule's point structure, or
176
- `get_lunar_enabled()` reading incorrectly -- and those need to be told
177
- apart with real data rather than guessed at. If you ever see a value that
178
- doesn't match the app, check these fields first before assuming which
179
- piece is wrong:
180
+ applied) -- useful for telling apart which branch/scalar produced a given
181
+ value, e.g. when a computed value doesn't match the app's own displayed
182
+ one and it isn't obvious whether `is_night_segment()` misjudged a
183
+ schedule's point structure or `get_lunar_enabled()` read incorrectly:
180
184
 
181
185
  ```python
182
186
  result = await device.get_current_light_intensities()
@@ -198,26 +202,19 @@ intensity values above `1000` permille (100%) on the wire, up to `2000`
198
202
  percentage is wrong once it's above 1000 -- the true percentage depends on
199
203
  how much headroom that specific channel actually has.
200
204
 
201
- `get_current_light_intensities()` (and `interpolate_light_schedule()`
202
- underneath it) already handled boosted raw values correctly all along --
203
- nothing was capped or clamped, so the interpolation itself was never
204
- wrong. What was missing was converting a boosted raw value to an accurate
205
- *percentage* for display.
206
-
207
- `get_current_light_percentages()` is the corrected alternative: it fetches
205
+ `get_current_light_percentages()` accounts for this: it fetches
208
206
  `MaxPower`/`NormalPower` (`get_channel_power_info()`, attributes `1504`/
209
207
  `1513`) and applies `channel_percent_value()` (`mobius.power`, ported from
210
- `LightPowerInfo.getPercentValue()`) per channel. For the overwhelming
211
- common case -- non-hyperdrive channels/devices, i.e. any raw value at or
212
- below 1000 -- this gives byte-identical results to the old `raw/10`
213
- shortcut; the extra machinery only changes anything for genuinely boosted
214
- channels. It also flags which channels are currently boosted via
215
- `.diagnostics["hyperdrive_channels"]`.
208
+ `LightPowerInfo.getPercentValue()`) per channel. For the common case --
209
+ non-hyperdrive channels/devices, i.e. any raw value at or below 1000 --
210
+ this gives the same results as a simple `raw/10`; the extra machinery
211
+ only changes anything for genuinely boosted channels. It also flags which
212
+ channels are currently boosted via `.diagnostics["hyperdrive_channels"]`.
216
213
 
217
214
  This costs extra BLE round trips beyond `get_current_light_intensities()`
218
215
  alone (two more `Get`s for `MaxPower`/`NormalPower`). Use
219
- `get_current_light_intensities()` directly if you don't need hyperdrive
220
- accuracy or want to minimize round trips.
216
+ `get_current_light_intensities()` directly if hyperdrive accuracy isn't
217
+ needed, or to minimize round trips.
221
218
 
222
219
  **Not replicated**: `LightPowerInfo`'s `gm8b4()` lookup table -- a
223
220
  hardcoded wattage override for a handful of specific AquaIllumination