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.
- python_mobius-0.3.0/.forgejo/scripts/extract_changelog_section.py +42 -0
- python_mobius-0.3.0/.forgejo/workflows/release.yml +110 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/CHANGELOG.md +79 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/PKG-INFO +1 -1
- {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/00-overview.md +1 -1
- {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/06-light-schedule.md +65 -68
- python_mobius-0.3.0/documentation/09-thread-coap-relay.md +214 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/10-known-gaps-and-open-questions.md +0 -11
- {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/11-device-status-attributes.md +64 -27
- python_mobius-0.3.0/documentation/12-device-identity-and-address-stability.md +105 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/pyproject.toml +1 -1
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/__init__.py +5 -1
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/constants.py +172 -5
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/device.py +155 -69
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/modifiers.py +22 -8
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/relay.py +38 -8
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_device_summary.py +22 -11
- python_mobius-0.3.0/tests/test_firmware_versions.py +298 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_hyperdrive_device.py +20 -20
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_light_intensity_branching.py +17 -13
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_modifiers.py +40 -4
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_relay.py +75 -0
- python_mobius-0.2.0/documentation/09-thread-coap-relay.md +0 -337
- python_mobius-0.2.0/documentation/12-device-identity-and-address-stability.md +0 -114
- python_mobius-0.2.0/tests/test_firmware_versions.py +0 -141
- {python_mobius-0.2.0 → python_mobius-0.3.0}/.gitignore +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/LICENSE +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/README.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/01-ble-transport.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/02-framing-and-crc.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/03-attributes-and-opcodes.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/04-device-identity.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/05-scenes.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/07-pump-schedule.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/documentation/08-manufacturer-data.md +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/cli.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/coap.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/crc.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/device_status.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/discovery.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/frame.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/manufacturer.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/mesh_address.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/power.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/pump_status.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/src/mobius/schedule.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_coap.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_coap_indication_handling.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_connection_cleanup.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_device_status.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_device_time.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_frame.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_frame_sniffer.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_insolation_detection.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_light_schedule.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_manufacturer.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_manufacturer_lookup.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_mesh_address.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_mesh_discovery.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_mesh_discovery_direct_connect.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_power.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_pump_schedule.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_pump_status.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_schedule_intensity_scalar.py +0 -0
- {python_mobius-0.2.0 → python_mobius-0.3.0}/tests/test_serial_decoding.py +0 -0
- {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.
|
|
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) |
|
|
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
|
|
12
|
-
|
|
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
|
|
17
|
-
|
|
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()`
|
|
31
|
+
`get_current_light_intensities()` applies it automatically.
|
|
38
32
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
80
|
-
lights
|
|
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
|
|
98
|
-
|
|
99
|
-
fetched live, which
|
|
100
|
-
|
|
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
|
|
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
|
|
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
|
|
121
|
-
dates (full moon Jan 3 2026, new moon Jan 18 2026) -- the ported
|
|
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
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
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"
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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()`
|
|
151
|
-
and
|
|
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
|
-
##
|
|
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)
|
|
174
|
-
computed value
|
|
175
|
-
`is_night_segment()`
|
|
176
|
-
`get_lunar_enabled()`
|
|
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
|
-
`
|
|
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
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
channels
|
|
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
|
|
220
|
-
|
|
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
|