python-mobius 0.2.1__tar.gz → 0.3.1__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.1/.forgejo/scripts/extract_changelog_section.py +42 -0
- python_mobius-0.3.1/.forgejo/workflows/release.yml +110 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/CHANGELOG.md +85 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/PKG-INFO +1 -1
- {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/06-light-schedule.md +70 -13
- {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/11-device-status-attributes.md +31 -11
- {python_mobius-0.2.1 → python_mobius-0.3.1}/pyproject.toml +1 -1
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/__init__.py +5 -1
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/constants.py +172 -5
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/device.py +45 -12
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/modifiers.py +22 -8
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/schedule.py +50 -1
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_device_summary.py +14 -5
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_firmware_versions.py +73 -3
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_light_intensity_branching.py +17 -13
- python_mobius-0.3.1/tests/test_light_schedule.py +135 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_modifiers.py +40 -4
- python_mobius-0.2.1/tests/test_light_schedule.py +0 -58
- {python_mobius-0.2.1 → python_mobius-0.3.1}/.gitignore +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/LICENSE +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/README.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/00-overview.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/01-ble-transport.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/02-framing-and-crc.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/03-attributes-and-opcodes.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/04-device-identity.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/05-scenes.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/07-pump-schedule.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/08-manufacturer-data.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/09-thread-coap-relay.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/10-known-gaps-and-open-questions.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/12-device-identity-and-address-stability.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/cli.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/coap.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/crc.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/device_status.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/discovery.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/frame.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/manufacturer.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/mesh_address.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/power.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/pump_status.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/relay.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_coap.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_coap_indication_handling.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_connection_cleanup.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_device_status.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_device_time.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_frame.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_frame_sniffer.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_hyperdrive_device.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_insolation_detection.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_manufacturer.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_manufacturer_lookup.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_mesh_address.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_mesh_discovery.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_mesh_discovery_direct_connect.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_power.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_pump_schedule.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_pump_status.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_relay.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_schedule_intensity_scalar.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_serial_decoding.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.1}/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,90 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.3.1
|
|
4
|
+
|
|
5
|
+
- **Fixed a real, high-impact bug: `Brightness` (the master dimmer
|
|
6
|
+
channel) was never being applied to any other channel at all.**
|
|
7
|
+
Confirmed directly from raw smali (`LightPrimitive.flatten()`, called
|
|
8
|
+
on both bracketing schedule points in `getIntensitiesAtTime()` before
|
|
9
|
+
interpolating between them -- JADX's decompiled Java for this class
|
|
10
|
+
carried its own "Code duplicated" warning, the same signal that led to
|
|
11
|
+
finding the `is_night_segment()` bug, so this was re-verified against
|
|
12
|
+
bytecode rather than trusted from the decompile a second time). Every
|
|
13
|
+
channel -- moonlight included -- was being interpolated and returned
|
|
14
|
+
without ever being scaled by `Brightness`, found via real hardware
|
|
15
|
+
testing: a light showing ~1% in the app's own display came back as
|
|
16
|
+
~25% from this library at the same moment, with `Brightness` itself
|
|
17
|
+
reading ~3.3% at that point in its own ramp -- fully consistent with
|
|
18
|
+
the missing multiplication, not a lunar or scheduling bug.
|
|
19
|
+
`interpolate_light_schedule()` now flattens each bracketing point
|
|
20
|
+
SEPARATELY (by that point's own `Brightness` value) BEFORE
|
|
21
|
+
interpolating between the two already-flattened points -- confirmed
|
|
22
|
+
via a dedicated test that this is NOT mathematically equivalent to
|
|
23
|
+
interpolating raw values first and multiplying by a single
|
|
24
|
+
interpolated `Brightness` at the end, whenever `Brightness` itself
|
|
25
|
+
changes across the segment (which it usually does, e.g. ramping
|
|
26
|
+
during dusk/dawn). `Brightness` itself is still returned as the plain
|
|
27
|
+
raw interpolated value, not flattened to a constant 100% the way the
|
|
28
|
+
real `flatten()` does to the primitive it's applied to -- callers
|
|
29
|
+
(e.g. a dedicated `Brightness` sensor) want the actual current
|
|
30
|
+
master-dimmer level. 4 new tests.
|
|
31
|
+
|
|
32
|
+
## 0.3.0
|
|
33
|
+
|
|
34
|
+
- **Fixed a real, high-impact bug in `is_night_segment()`: the condition
|
|
35
|
+
deciding whether to apply lunar-phase reduction had its first check
|
|
36
|
+
inverted**, confirmed directly from raw smali bytecode after JADX's
|
|
37
|
+
own decompiled Java carried a "Code duplicated" warning on this exact
|
|
38
|
+
method that turned out to be a real signal -- the decompiler had
|
|
39
|
+
produced `!point.has(NIGHT)` where the actual bytecode requires
|
|
40
|
+
`point.has(NIGHT)`. In practice this meant lunar reduction was applied
|
|
41
|
+
only during the single dusk-transition segment (and only if the query
|
|
42
|
+
time exactly matched a night-flagged point), while every other
|
|
43
|
+
night-to-night segment -- the bulk of an actual night -- silently used
|
|
44
|
+
the schedule-intensity scalar instead. Found via real hardware
|
|
45
|
+
testing: a light in a `23:00[NIGHT,SUNSET] -> 23:27[NIGHT]` segment
|
|
46
|
+
produced a value consistent with schedule-intensity-only scaling from
|
|
47
|
+
this library, while the real app displayed a noticeably lower,
|
|
48
|
+
lunar-consistent value at the same moment -- mathematically impossible
|
|
49
|
+
to explain by schedule-intensity alone within that segment's raw-value
|
|
50
|
+
range. 4 tests fixed/rewritten to match the confirmed correct
|
|
51
|
+
semantics (including one reproducing the exact real-world schedule and
|
|
52
|
+
moment this was found from), plus 2 more updated in
|
|
53
|
+
`test_light_intensity_branching.py` whose fixture schedule needed to
|
|
54
|
+
actually be a night-to-night segment rather than the (no longer
|
|
55
|
+
qualifying) dusk-transition segment it used before.
|
|
56
|
+
|
|
57
|
+
- **Fixed a real bug, the same class of gap this project has hit before:
|
|
58
|
+
`get_hardware_info()` was never wired into `get_device_summary()`,**
|
|
59
|
+
meaning it worked correctly in isolation (with its own passing tests)
|
|
60
|
+
but was silently missing from the CLI's output and any other "give me
|
|
61
|
+
everything" caller built on the summary entrypoint -- see
|
|
62
|
+
`get_device_summary()`'s own "BUG NOTE" docstring, which describes the
|
|
63
|
+
exact same thing having happened to `get_group_info()`/
|
|
64
|
+
`get_calibration_info()`/etc. previously. Added `info["hardware_info"]
|
|
65
|
+
= await self.get_hardware_info()` alongside the existing
|
|
66
|
+
`firmware_versions` line. The CLI needed no changes at all -- its
|
|
67
|
+
summary printing is a generic loop over whatever `get_device_summary()`
|
|
68
|
+
returns, so this alone was the actual fix. 2 tests extended (not just
|
|
69
|
+
`"key" in summary`, but the actual decoded value) specifically so this
|
|
70
|
+
field can't silently regress the way it silently went missing.
|
|
71
|
+
|
|
72
|
+
- **`get_hardware_info()` now decodes `Color`/`ProductType`/`RadioType`/
|
|
73
|
+
`MotorType` into confirmed display labels**, instead of returning raw
|
|
74
|
+
bytes. Each is itself a confirmed enum with confirmed labels
|
|
75
|
+
(`M.Color`/`M.ProductType`/`M.RadioType`/`M.MotorType` in the
|
|
76
|
+
decompile) -- added `Color`, `RadioType`, `MotorType`, `ProductType`
|
|
77
|
+
enums plus their `*_LABELS` dicts to `mobius.constants`. An
|
|
78
|
+
unrecognized value falls back to `"Unknown (N)"` rather than raising.
|
|
79
|
+
`Revision`/`Segments` still have no confirmed enum meaning -- returned
|
|
80
|
+
as plain integers (previously raw bytes) rather than formatted.
|
|
81
|
+
Several `MotorType`/`ProductType` labels were base64-string-obfuscated
|
|
82
|
+
in the decompile rather than plain literals (apparently to avoid
|
|
83
|
+
casual discovery of unannounced product names in a simple APK string
|
|
84
|
+
dump) -- decoded and included regardless, since it's plain base64, not
|
|
85
|
+
any actual protection. 3 new tests. **Breaking change** for any caller
|
|
86
|
+
relying on `get_hardware_info()`'s previous raw-bytes return shape.
|
|
87
|
+
|
|
3
88
|
## 0.2.1
|
|
4
89
|
|
|
5
90
|
- **Fixed a real bug found via real hardware testing: some "get all
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-mobius
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.1
|
|
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
|
|
@@ -81,12 +81,51 @@ Ported from `PointSchedule.getIntensitiesAtTime()`:
|
|
|
81
81
|
2. Find the two points that bracket the target minute-of-day, wrapping
|
|
82
82
|
around midnight (the point after the last one is treated as being on
|
|
83
83
|
"the next day," i.e. its time + 1440).
|
|
84
|
-
3.
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
3. **`Brightness` (`VisualID.Brightness`) is a master dimmer, applied to
|
|
85
|
+
EACH bracketing point separately, before interpolating between them**
|
|
86
|
+
-- confirmed from raw smali (`LightPrimitive.flatten()`, called on
|
|
87
|
+
both point copies in `getIntensitiesAtTime()` before the
|
|
88
|
+
interpolation loop -- JADX's decompiled Java for this class carried
|
|
89
|
+
its own "Code duplicated" warning, the same signal that led to
|
|
90
|
+
finding the `is_night_segment()` bug, so this was verified against
|
|
91
|
+
bytecode directly rather than trusted from the decompile). For each
|
|
92
|
+
point independently: every channel except `Brightness` itself is
|
|
93
|
+
multiplied by that same point's own `Brightness` value / 1000. This
|
|
94
|
+
was a real, confirmed-missing step for a while -- every channel
|
|
95
|
+
(moonlight included) was being interpolated and returned without
|
|
96
|
+
ever being scaled by `Brightness` at all, found via real hardware
|
|
97
|
+
testing (a light showing ~1% in the app's own display came back as
|
|
98
|
+
~25% from this library at the same moment, with `Brightness` itself
|
|
99
|
+
reading ~3.3% at that point in its own ramp).
|
|
100
|
+
4. For each channel present in either bracketing point, linearly
|
|
101
|
+
interpolate between the (already `Brightness`-scaled, for
|
|
102
|
+
non-`Brightness` channels) endpoint values:
|
|
103
|
+
`value(t) = v1 + (v2 - v1) * (t - t1) / (t2 - t1)`.
|
|
104
|
+
**Flattening happens per-endpoint before this step, not
|
|
105
|
+
interpolate-raw-then-multiply-by-a-single-`Brightness`-value after
|
|
106
|
+
it** -- these are NOT mathematically equivalent whenever `Brightness`
|
|
107
|
+
itself changes across the segment (which it usually does, e.g.
|
|
108
|
+
ramping during a dusk/dawn transition).
|
|
109
|
+
5. Channels absent from a point default to `0` (off), except
|
|
110
|
+
`Brightness` itself: a point missing that channel entirely is treated
|
|
111
|
+
as a no-op 1000 (100%) multiplier for that point, matching
|
|
112
|
+
`flatten()`'s own `visuals().contains(Brightness)` guard (skips
|
|
113
|
+
scaling entirely rather than treating an absent value as 0, which
|
|
114
|
+
would zero out every other channel).
|
|
115
|
+
6. `Brightness` itself is returned as the plain raw interpolated value
|
|
116
|
+
between the two points' own `Brightness` levels -- NOT flattened to
|
|
117
|
+
1000 the way the real `flatten()` call does internally to the
|
|
118
|
+
primitive it's applied to. Unlike the app's own dashboard (which
|
|
119
|
+
doesn't display `Brightness` as its own value at all, only as the
|
|
120
|
+
multiplier baked into every other channel), this library's callers
|
|
121
|
+
(e.g. a dedicated `Brightness` sensor) want the actual current
|
|
122
|
+
master-dimmer level itself.
|
|
87
123
|
|
|
88
124
|
Verified against a synthetic two-point schedule (exact endpoint values,
|
|
89
|
-
midpoint blending,
|
|
125
|
+
midpoint blending, midnight-wraparound blending, and the `Brightness`
|
|
126
|
+
master-dimmer behavior above, specifically including a case designed to
|
|
127
|
+
mathematically distinguish flatten-then-interpolate from the wrong
|
|
128
|
+
interpolate-then-multiply alternative -- see
|
|
90
129
|
`tests/test_light_schedule.py`) and against a real 9-point schedule
|
|
91
130
|
fetched live, which produces a coherent daily light-intensity curve (dim
|
|
92
131
|
moonlight overnight → ramp to full blues/UV midday → wind back down).
|
|
@@ -110,18 +149,36 @@ full moon. Verified against independently-confirmed real 2026 moon phase
|
|
|
110
149
|
dates (full moon Jan 3 2026, new moon Jan 18 2026) -- the ported
|
|
111
150
|
algorithm matches exactly.
|
|
112
151
|
|
|
113
|
-
This replaces the normal schedule-intensity scalar
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
152
|
+
This replaces the normal schedule-intensity scalar for any segment
|
|
153
|
+
where `is_night_segment()` is true (see below), and only when
|
|
154
|
+
`LunarPhasesEnabled` (attribute 907) is set on the device -- otherwise
|
|
155
|
+
that segment just uses a flat `1.0` (full/unreduced) scalar, matching
|
|
156
|
+
the app's own fallback.
|
|
118
157
|
|
|
119
158
|
**`is_night_segment()`**: ported from the boolean half of
|
|
120
159
|
`PointSchedule.getIntensitiesAtTime()`'s return value. This is *not*
|
|
121
|
-
simply "is it night" -- it's specifically true for
|
|
122
|
-
start point
|
|
123
|
-
|
|
124
|
-
|
|
160
|
+
simply "is it night" as a general concept -- it's specifically true for
|
|
161
|
+
the segment whose **start** point IS flagged `NIGHT` (but not also
|
|
162
|
+
`SUNRISE`) and whose end point is ALSO flagged `NIGHT` -- i.e. once
|
|
163
|
+
already into the night portion of the schedule, through to the sunrise
|
|
164
|
+
transition -- or when the query time exactly matches a `NIGHT`-flagged
|
|
165
|
+
point. In practice this covers essentially the whole night, *except*
|
|
166
|
+
the two transition segments themselves: dusk (start point not yet
|
|
167
|
+
night-flagged) and the sunrise segment (start point flagged `SUNRISE`).
|
|
168
|
+
|
|
169
|
+
**Confirmed directly from raw smali bytecode, not the decompiled Java.**
|
|
170
|
+
JADX's decompiler carried its own "Code duplicated" warning on this
|
|
171
|
+
exact method -- a real signal, not noise: its decompiled Java had this
|
|
172
|
+
condition's first check inverted (`!point.has(NIGHT)` instead of the
|
|
173
|
+
actual `point.has(NIGHT)`), which an earlier version of this port
|
|
174
|
+
carried forward uncorrected. Found via real hardware testing: a light
|
|
175
|
+
in a `23:00[NIGHT,SUNSET] -> 23:27[NIGHT]` segment showed a value
|
|
176
|
+
consistent with the schedule-intensity scalar from this library, while
|
|
177
|
+
the real app displayed a noticeably lower, lunar-consistent value at
|
|
178
|
+
the same moment -- mathematically impossible to explain by
|
|
179
|
+
schedule-intensity alone within that segment's raw-value range, which
|
|
180
|
+
is what prompted re-examining the raw bytecode instead of trusting the
|
|
181
|
+
decompiled Java a second time.
|
|
125
182
|
|
|
126
183
|
**Acclimation ramp** (`AcclimationInfo`): a linear intensity ramp from
|
|
127
184
|
`start_intensity` (0-1000 permille) up to full (1000) over `period_days`,
|
|
@@ -170,17 +170,37 @@ response bytes** (e.g. `[4, 0, 21]` -> `"4.0.21"`) -- confirmed via
|
|
|
170
170
|
nothing more exotic.
|
|
171
171
|
|
|
172
172
|
`get_hardware_info()` follows the same fetch pattern for
|
|
173
|
-
`HardwareRevision`/`HardwareInfo
|
|
174
|
-
|
|
175
|
-
`RadioType`/`MotorType
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
`
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
173
|
+
`HardwareRevision`/`HardwareInfo`. `Color`/`ProductType`/`RadioType`/
|
|
174
|
+
`MotorType` are each themselves confirmed enums with confirmed display
|
|
175
|
+
labels (`M.Color`/`M.ProductType`/`M.RadioType`/`M.MotorType` in the
|
|
176
|
+
decompile -- see `mobius.constants`) -- decoded into those label
|
|
177
|
+
strings, e.g. `{"Color": "White", "MotorType": "VorTech MP40 G3"}`. An
|
|
178
|
+
unrecognized value (firmware newer than this library's confirmed enum
|
|
179
|
+
coverage) falls back to `"Unknown (N)"` rather than raising.
|
|
180
|
+
`Revision`/`Segments` have no confirmed enum meaning -- returned as
|
|
181
|
+
plain integers, not formatted.
|
|
182
|
+
|
|
183
|
+
Several `MotorType`/`ProductType` labels are base64-string-obfuscated in
|
|
184
|
+
the decompile rather than plain literals (`Slack`/`Alpaca2`/`Alpaca4`/
|
|
185
|
+
`Turtle1-4` under `MotorType`; several under `ProductType`) -- apparently
|
|
186
|
+
to avoid casual discovery of unannounced product names in a simple APK
|
|
187
|
+
string dump. Decoded and included as confirmed labels regardless, since
|
|
188
|
+
it's plain base64, not any actual protection -- e.g. `MotorType.Turtle1`
|
|
189
|
+
decodes to `"Axis 20"`, `ProductType.Cowboy` (a dedicated Thread hub
|
|
190
|
+
device, see [09-thread-coap-relay.md](./09-thread-coap-relay.md)) to
|
|
191
|
+
`"MXM"`. Some `ProductType` labels also intentionally diverge from the
|
|
192
|
+
enum member's own name (`Chalupa` -> `"Sprout"`, `NachosBellGrande` ->
|
|
193
|
+
`"Voltra"`) -- confirmed real display strings, not typos.
|
|
194
|
+
|
|
195
|
+
Both are included automatically in `get_device_summary()`, as
|
|
196
|
+
`"firmware_versions"` (using the confirmed EcoTech labels, since
|
|
197
|
+
`get_device_summary()` already has the device's `Model` on hand) and
|
|
198
|
+
`"hardware_info"` respectively -- confirmed reaching the CLI's output
|
|
199
|
+
this way, not just working correctly in isolation (see
|
|
200
|
+
`get_device_summary()`'s own "BUG NOTE" docstring for why this
|
|
201
|
+
distinction matters: this exact class of gap, a method that works
|
|
202
|
+
correctly on its own but was never wired into the aggregate entrypoint
|
|
203
|
+
the CLI actually uses, has happened more than once in this project).
|
|
184
204
|
|
|
185
205
|
## Device clock drift (`get_device_time_info()`)
|
|
186
206
|
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-mobius"
|
|
7
|
-
version = "0.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { text = "GPL-2.0-only" }
|
|
@@ -28,6 +28,8 @@ from .constants import (
|
|
|
28
28
|
SceneID, OperationState, FsciStatus,
|
|
29
29
|
PumpMode, PumpOverrideMode, RampType, PumpParam, PUMP_PARAM_SIZE, PUMP_MODE_PARAMS,
|
|
30
30
|
FirmwareType, FIRMWARE_TYPE_LABELS_ETM, HardwareInfo,
|
|
31
|
+
Color, COLOR_LABELS, RadioType, RADIO_TYPE_LABELS,
|
|
32
|
+
MotorType, MOTOR_TYPE_LABELS, ProductType, PRODUCT_TYPE_LABELS,
|
|
31
33
|
)
|
|
32
34
|
from .schedule import (
|
|
33
35
|
LightPrimitive, SchedulePoint, interpolate_light_schedule,
|
|
@@ -61,7 +63,7 @@ from .discovery import (
|
|
|
61
63
|
dedupe_by_serial, find_device_by_serial, discover_mesh_peers_via_direct_connect,
|
|
62
64
|
)
|
|
63
65
|
|
|
64
|
-
__version__ = "0.
|
|
66
|
+
__version__ = "0.3.1"
|
|
65
67
|
|
|
66
68
|
__all__ = [
|
|
67
69
|
"__version__",
|
|
@@ -83,6 +85,8 @@ __all__ = [
|
|
|
83
85
|
"SceneID", "OperationState", "FsciStatus",
|
|
84
86
|
"PumpMode", "PumpOverrideMode", "RampType", "PumpParam", "PUMP_PARAM_SIZE", "PUMP_MODE_PARAMS",
|
|
85
87
|
"FirmwareType", "FIRMWARE_TYPE_LABELS_ETM", "HardwareInfo",
|
|
88
|
+
"Color", "COLOR_LABELS", "RadioType", "RADIO_TYPE_LABELS",
|
|
89
|
+
"MotorType", "MOTOR_TYPE_LABELS", "ProductType", "PRODUCT_TYPE_LABELS",
|
|
86
90
|
# schedule
|
|
87
91
|
"LightPrimitive", "SchedulePoint", "interpolate_light_schedule",
|
|
88
92
|
"PumpPrimitiveValue", "PumpSchedulePoint", "get_active_pump_block",
|
|
@@ -492,11 +492,12 @@ class HardwareInfo(IntEnum):
|
|
|
492
492
|
"""
|
|
493
493
|
Confirmed literal values from M.HardwareInfo -- the sub-index used
|
|
494
494
|
with C2Attribute.HardwareRevision, same "get all elements" pattern as
|
|
495
|
-
FirmwareVersion above.
|
|
496
|
-
display convention
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
495
|
+
FirmwareVersion above. `Revision` and `Segments` have no confirmed
|
|
496
|
+
display convention (read as plain small integers, not version
|
|
497
|
+
numbers or enum codes) -- but `Color`/`ProductType`/`RadioType`/
|
|
498
|
+
`MotorType` are each themselves confirmed enums (M.Color/M.ProductType/
|
|
499
|
+
M.RadioType/M.MotorType) with confirmed display labels -- see
|
|
500
|
+
Color/ProductType/RadioType/MotorType below and their *_LABELS dicts.
|
|
500
501
|
"""
|
|
501
502
|
Unknown = 0
|
|
502
503
|
Color = 1
|
|
@@ -507,6 +508,172 @@ class HardwareInfo(IntEnum):
|
|
|
507
508
|
Segments = 6
|
|
508
509
|
|
|
509
510
|
|
|
511
|
+
class Color(IntEnum):
|
|
512
|
+
"""Confirmed literal values and display labels from M.Color --
|
|
513
|
+
the HardwareInfo.Color sub-value's own enum."""
|
|
514
|
+
Unknown = 0
|
|
515
|
+
Black = 1
|
|
516
|
+
White = 2
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
COLOR_LABELS: dict = {
|
|
520
|
+
Color.Black: "Black",
|
|
521
|
+
Color.White: "White",
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
|
|
525
|
+
class RadioType(IntEnum):
|
|
526
|
+
"""Confirmed literal values and display labels from M.RadioType --
|
|
527
|
+
the HardwareInfo.RadioType sub-value's own enum."""
|
|
528
|
+
Unknown = 0
|
|
529
|
+
KW41 = 1
|
|
530
|
+
QCA4020 = 2
|
|
531
|
+
K32W = 3
|
|
532
|
+
QCA4024 = 4
|
|
533
|
+
ESP32C3 = 5
|
|
534
|
+
ESP32H2 = 6
|
|
535
|
+
ESP32S3 = 7
|
|
536
|
+
ESP32C6 = 8
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
RADIO_TYPE_LABELS: dict = {
|
|
540
|
+
RadioType.KW41: "KW41",
|
|
541
|
+
RadioType.QCA4020: "QCA4020",
|
|
542
|
+
RadioType.K32W: "K32W",
|
|
543
|
+
RadioType.QCA4024: "QCA4024",
|
|
544
|
+
RadioType.ESP32C3: "ESP32C3",
|
|
545
|
+
RadioType.ESP32H2: "ESP32H2",
|
|
546
|
+
RadioType.ESP32S3: "ESP32S3",
|
|
547
|
+
RadioType.ESP32C6: "ESP32C6",
|
|
548
|
+
}
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
class MotorType(IntEnum):
|
|
552
|
+
"""Confirmed literal values and display labels from M.MotorType --
|
|
553
|
+
the HardwareInfo.MotorType sub-value's own enum. Several labels
|
|
554
|
+
(Slack/Alpaca2/Alpaca4/Turtle1-4) are base64-string-obfuscated in the
|
|
555
|
+
decompile rather than plain literals, apparently to avoid casual
|
|
556
|
+
discovery of unannounced product names in a simple APK string dump --
|
|
557
|
+
decoded here since it's plain base64, not encryption, and the
|
|
558
|
+
resulting strings are just as much "confirmed from source" as the
|
|
559
|
+
unobfuscated ones."""
|
|
560
|
+
Unknown = 0
|
|
561
|
+
VorTechMP10 = 1
|
|
562
|
+
VorTechMP40Legacy = 2
|
|
563
|
+
VorTechMP40G3 = 3
|
|
564
|
+
VorTechMP60 = 4
|
|
565
|
+
VectraD12 = 5
|
|
566
|
+
VectraD8 = 6
|
|
567
|
+
VectraS1 = 7
|
|
568
|
+
Wavepuck30w = 8
|
|
569
|
+
Nero5 = 9
|
|
570
|
+
Nero3 = 10
|
|
571
|
+
VectraS2 = 11
|
|
572
|
+
VectraM2 = 12
|
|
573
|
+
VectraL2 = 13
|
|
574
|
+
VersaVX1 = 14
|
|
575
|
+
Slack = 15
|
|
576
|
+
Alpaca2 = 16
|
|
577
|
+
Alpaca4 = 17
|
|
578
|
+
Turtle1 = 18
|
|
579
|
+
Turtle2 = 19
|
|
580
|
+
Turtle3 = 20
|
|
581
|
+
Turtle4 = 21
|
|
582
|
+
Coffee1 = 22
|
|
583
|
+
Coffee2 = 23
|
|
584
|
+
VectraTester = 99
|
|
585
|
+
|
|
586
|
+
|
|
587
|
+
MOTOR_TYPE_LABELS: dict = {
|
|
588
|
+
MotorType.VorTechMP10: "VorTech MP10",
|
|
589
|
+
MotorType.VorTechMP40Legacy: "VorTech MP40 Legacy",
|
|
590
|
+
MotorType.VorTechMP40G3: "VorTech MP40 G3",
|
|
591
|
+
MotorType.VorTechMP60: "VorTech MP60",
|
|
592
|
+
MotorType.VectraD12: "Vectra D12",
|
|
593
|
+
MotorType.VectraD8: "Vectra D8",
|
|
594
|
+
MotorType.VectraS1: "Vectra S1",
|
|
595
|
+
MotorType.Wavepuck30w: "Wavepuck 30w",
|
|
596
|
+
MotorType.Nero5: "Nero 5",
|
|
597
|
+
MotorType.Nero3: "Nero 3",
|
|
598
|
+
MotorType.VectraS2: "Vectra S2",
|
|
599
|
+
MotorType.VectraM2: "Vectra M2",
|
|
600
|
+
MotorType.VectraL2: "Vectra L2",
|
|
601
|
+
MotorType.VersaVX1: "Versa VX1",
|
|
602
|
+
MotorType.Slack: "Nero 7",
|
|
603
|
+
MotorType.Alpaca2: "Orbit 2",
|
|
604
|
+
MotorType.Alpaca4: "Orbit 4",
|
|
605
|
+
MotorType.Turtle1: "Axis 20",
|
|
606
|
+
MotorType.Turtle2: "Axis 40",
|
|
607
|
+
MotorType.Turtle3: "Axis 90",
|
|
608
|
+
MotorType.Turtle4: "Axis 200",
|
|
609
|
+
MotorType.Coffee1: "Coffee 1",
|
|
610
|
+
MotorType.Coffee2: "Coffee 2",
|
|
611
|
+
MotorType.VectraTester: "Vectra Tester",
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
class ProductType(IntEnum):
|
|
616
|
+
"""Confirmed literal values and display labels from M.ProductType --
|
|
617
|
+
the HardwareInfo.ProductType sub-value's own enum. Several labels
|
|
618
|
+
are base64-string-obfuscated in the decompile the same way some
|
|
619
|
+
MotorType labels are -- see MotorType's docstring for why that's
|
|
620
|
+
still treated as confirmed. Note several of these labels intentionally
|
|
621
|
+
diverge from the enum member's own name (e.g. Chalupa -> "Sprout",
|
|
622
|
+
NachosBellGrande -> "Voltra", Cowboy -> "MXM") -- these are the actual
|
|
623
|
+
confirmed display strings, not typos."""
|
|
624
|
+
Unknown = 0
|
|
625
|
+
Radion = 1
|
|
626
|
+
Vectra = 2
|
|
627
|
+
VorTech = 3
|
|
628
|
+
Nero = 4
|
|
629
|
+
DosingPump = 5
|
|
630
|
+
Chalupa = 6
|
|
631
|
+
EnvironmentalSensor = 7
|
|
632
|
+
RFModule = 8
|
|
633
|
+
WiFiModule = 9
|
|
634
|
+
WiFiModuleBreakout = 10
|
|
635
|
+
ChalupaPowerSupply = 11
|
|
636
|
+
ChalupaDriver = 12
|
|
637
|
+
AIFiLight = 13
|
|
638
|
+
AIMobiusLight = 14
|
|
639
|
+
DosingPumpBase = 15
|
|
640
|
+
NachosBellGrande = 16
|
|
641
|
+
HotSauce = 17
|
|
642
|
+
Cowboy = 18
|
|
643
|
+
Alpaca = 19
|
|
644
|
+
Turtle = 20
|
|
645
|
+
Coffee = 21
|
|
646
|
+
CrunchyTaco = 22
|
|
647
|
+
SoftTaco = 23
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
PRODUCT_TYPE_LABELS: dict = {
|
|
651
|
+
ProductType.Radion: "Radion",
|
|
652
|
+
ProductType.Vectra: "Vectra",
|
|
653
|
+
ProductType.VorTech: "VorTech",
|
|
654
|
+
ProductType.Nero: "Nero",
|
|
655
|
+
ProductType.DosingPump: "Dosing Pump",
|
|
656
|
+
ProductType.Chalupa: "Sprout",
|
|
657
|
+
ProductType.EnvironmentalSensor: "Sensor",
|
|
658
|
+
ProductType.RFModule: "RF Module",
|
|
659
|
+
ProductType.WiFiModule: "Wi-Fi Module",
|
|
660
|
+
ProductType.WiFiModuleBreakout: "Breakout",
|
|
661
|
+
ProductType.ChalupaPowerSupply: "Power Supply",
|
|
662
|
+
ProductType.ChalupaDriver: "Driver",
|
|
663
|
+
ProductType.AIFiLight: "AI-Fi Light",
|
|
664
|
+
ProductType.AIMobiusLight: "AI Light",
|
|
665
|
+
ProductType.DosingPumpBase: "Base Station",
|
|
666
|
+
ProductType.NachosBellGrande: "Voltra",
|
|
667
|
+
ProductType.HotSauce: "Sensor",
|
|
668
|
+
ProductType.Cowboy: "MXM",
|
|
669
|
+
ProductType.Alpaca: "Orbit",
|
|
670
|
+
ProductType.Turtle: "Axis",
|
|
671
|
+
ProductType.Coffee: "Quantum",
|
|
672
|
+
ProductType.CrunchyTaco: "Hera",
|
|
673
|
+
ProductType.SoftTaco: "Hera Backplane",
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
|
|
510
677
|
class PumpOverrideMode(IntEnum):
|
|
511
678
|
"""
|
|
512
679
|
Confirmed literal values from M.PumpOverrideMode. Confirmed only as a
|