python-mobius 0.2.1__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.1 → python_mobius-0.3.0}/CHANGELOG.md +56 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/PKG-INFO +1 -1
- {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/06-light-schedule.md +27 -9
- {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/11-device-status-attributes.md +31 -11
- {python_mobius-0.2.1 → python_mobius-0.3.0}/pyproject.toml +1 -1
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/__init__.py +5 -1
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/constants.py +172 -5
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/device.py +45 -12
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/modifiers.py +22 -8
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_device_summary.py +14 -5
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_firmware_versions.py +73 -3
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_light_intensity_branching.py +17 -13
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_modifiers.py +40 -4
- {python_mobius-0.2.1 → python_mobius-0.3.0}/.gitignore +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/LICENSE +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/README.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/00-overview.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/01-ble-transport.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/02-framing-and-crc.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/03-attributes-and-opcodes.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/04-device-identity.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/05-scenes.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/07-pump-schedule.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/08-manufacturer-data.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/09-thread-coap-relay.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/10-known-gaps-and-open-questions.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/12-device-identity-and-address-stability.md +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/cli.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/coap.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/crc.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/device_status.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/discovery.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/frame.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/manufacturer.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/mesh_address.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/power.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/pump_status.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/relay.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/schedule.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_coap.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_coap_indication_handling.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_connection_cleanup.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_device_status.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_device_time.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_frame.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_frame_sniffer.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_hyperdrive_device.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_insolation_detection.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_light_schedule.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_manufacturer.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_manufacturer_lookup.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_mesh_address.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_mesh_discovery.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_mesh_discovery_direct_connect.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_power.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_pump_schedule.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_pump_status.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_relay.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_schedule_intensity_scalar.py +0 -0
- {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_serial_decoding.py +0 -0
- {python_mobius-0.2.1 → 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,61 @@
|
|
|
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
|
+
|
|
3
59
|
## 0.2.1
|
|
4
60
|
|
|
5
61
|
- **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.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
|
|
@@ -110,18 +110,36 @@ full moon. Verified against independently-confirmed real 2026 moon phase
|
|
|
110
110
|
dates (full moon Jan 3 2026, new moon Jan 18 2026) -- the ported
|
|
111
111
|
algorithm matches exactly.
|
|
112
112
|
|
|
113
|
-
This replaces the normal schedule-intensity scalar
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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.
|
|
118
118
|
|
|
119
119
|
**`is_night_segment()`**: ported from the boolean half of
|
|
120
120
|
`PointSchedule.getIntensitiesAtTime()`'s return value. This is *not*
|
|
121
|
-
simply "is it night" -- it's specifically true for
|
|
122
|
-
start point
|
|
123
|
-
|
|
124
|
-
|
|
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.
|
|
125
143
|
|
|
126
144
|
**Acclimation ramp** (`AcclimationInfo`): a linear intensity ramp from
|
|
127
145
|
`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.0"
|
|
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.0"
|
|
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
|
|
@@ -31,6 +31,8 @@ from .constants import (
|
|
|
31
31
|
C2Attribute, PrimitiveType, Model, ErrorState, SceneID, OperationState,
|
|
32
32
|
FsciStatus, PhysicalValueID, VisualID, PumpOverrideMode, manufacturer_for_model,
|
|
33
33
|
FirmwareType, FIRMWARE_TYPE_LABELS_ETM, HardwareInfo,
|
|
34
|
+
Color, COLOR_LABELS, RadioType, RADIO_TYPE_LABELS,
|
|
35
|
+
MotorType, MOTOR_TYPE_LABELS, ProductType, PRODUCT_TYPE_LABELS,
|
|
34
36
|
PRIMITIVE_SIZE, LIGHT_PRIMITIVES, PUMP_PRIMITIVES_VERIFIED, PUMP_PRIMITIVES_EXPERIMENTAL,
|
|
35
37
|
)
|
|
36
38
|
from .schedule import (
|
|
@@ -105,6 +107,18 @@ class LightIntensityResult(dict):
|
|
|
105
107
|
self.diagnostics = diagnostics or {}
|
|
106
108
|
|
|
107
109
|
|
|
110
|
+
# Used by get_hardware_info() -- which HardwareInfo sub-fields are
|
|
111
|
+
# themselves confirmed enums with confirmed display labels, vs. plain
|
|
112
|
+
# integers with no confirmed meaning (Revision/Segments, deliberately not
|
|
113
|
+
# in this map).
|
|
114
|
+
_HARDWARE_SUB_ENUM_LABELS = {
|
|
115
|
+
HardwareInfo.Color: (Color, COLOR_LABELS),
|
|
116
|
+
HardwareInfo.ProductType: (ProductType, PRODUCT_TYPE_LABELS),
|
|
117
|
+
HardwareInfo.RadioType: (RadioType, RADIO_TYPE_LABELS),
|
|
118
|
+
HardwareInfo.MotorType: (MotorType, MOTOR_TYPE_LABELS),
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
|
|
108
122
|
class MobiusDevice:
|
|
109
123
|
"""
|
|
110
124
|
High-level async client for a single Mobius-protocol BLE device.
|
|
@@ -828,12 +842,19 @@ class MobiusDevice:
|
|
|
828
842
|
-- same pattern as get_firmware_versions(), including using
|
|
829
843
|
get_attribute_raw_all() rather than get_attribute_raw() to avoid
|
|
830
844
|
missing elements if the response splits across more than one
|
|
831
|
-
block (see get_attribute_raw_all()'s docstring).
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
845
|
+
block (see get_attribute_raw_all()'s docstring).
|
|
846
|
+
|
|
847
|
+
`Color`/`ProductType`/`RadioType`/`MotorType` are each themselves
|
|
848
|
+
confirmed enums with confirmed display labels (M.Color/
|
|
849
|
+
M.ProductType/M.RadioType/M.MotorType in the decompile -- see
|
|
850
|
+
mobius.constants) -- decoded into those label strings here, e.g.
|
|
851
|
+
{"Color": "White", "MotorType": "VorTech MP40 G3"}. An
|
|
852
|
+
unrecognized value (firmware newer than this library's confirmed
|
|
853
|
+
enum coverage) falls back to "Unknown (N)" rather than raising.
|
|
854
|
+
|
|
855
|
+
`Revision`/`Segments` have no confirmed enum meaning -- returned
|
|
856
|
+
as plain integers, not formatted, since unlike the four fields
|
|
857
|
+
above there's nothing confirmed to decode them into.
|
|
837
858
|
"""
|
|
838
859
|
try:
|
|
839
860
|
blocks = await self.get_attribute_raw_all(C2Attribute.HardwareRevision, index=0, count=0xFFFF)
|
|
@@ -849,7 +870,17 @@ class MobiusDevice:
|
|
|
849
870
|
hw_info = HardwareInfo(raw.index + i)
|
|
850
871
|
except ValueError:
|
|
851
872
|
continue
|
|
852
|
-
|
|
873
|
+
if not value_bytes:
|
|
874
|
+
continue
|
|
875
|
+
raw_int = int.from_bytes(value_bytes, byteorder="little", signed=False)
|
|
876
|
+
if hw_info in _HARDWARE_SUB_ENUM_LABELS:
|
|
877
|
+
enum_cls, labels = _HARDWARE_SUB_ENUM_LABELS[hw_info]
|
|
878
|
+
try:
|
|
879
|
+
result[hw_info.name] = labels.get(enum_cls(raw_int), f"Unknown ({raw_int})")
|
|
880
|
+
except ValueError:
|
|
881
|
+
result[hw_info.name] = f"Unknown ({raw_int})"
|
|
882
|
+
else:
|
|
883
|
+
result[hw_info.name] = raw_int
|
|
853
884
|
return result
|
|
854
885
|
|
|
855
886
|
async def get_device_summary(self) -> dict:
|
|
@@ -873,11 +904,12 @@ class MobiusDevice:
|
|
|
873
904
|
Pump devices additionally include "flow_range", "pump_override_mode",
|
|
874
905
|
"battery_backup", "boosted_battery" (see get_pump_flow_range() etc.
|
|
875
906
|
for details/confidence notes on each). Every device (regardless of
|
|
876
|
-
support tier) additionally includes "group", "calibration",
|
|
877
|
-
"maintenance" (see get_group_info()/
|
|
878
|
-
get_maintenance_info()
|
|
879
|
-
|
|
880
|
-
documentation/
|
|
907
|
+
support tier) additionally includes "group", "calibration",
|
|
908
|
+
"maintenance", and "hardware_info" (see get_group_info()/
|
|
909
|
+
get_calibration_info()/get_maintenance_info()/get_hardware_info()
|
|
910
|
+
-- calibration in particular is expected to only actually
|
|
911
|
+
populate on lights, not pumps; see documentation/
|
|
912
|
+
11-device-status-attributes.md).
|
|
881
913
|
|
|
882
914
|
BUG NOTE: for a while after get_pump_flow_range()/
|
|
883
915
|
get_pump_override_mode()/get_battery_backup_info()/
|
|
@@ -959,6 +991,7 @@ class MobiusDevice:
|
|
|
959
991
|
info["calibration"] = await self.get_calibration_info()
|
|
960
992
|
info["maintenance"] = await self.get_maintenance_info()
|
|
961
993
|
info["firmware_versions"] = await self.get_firmware_versions(model)
|
|
994
|
+
info["hardware_info"] = await self.get_hardware_info()
|
|
962
995
|
info["device_time"] = await self.get_device_time_info()
|
|
963
996
|
|
|
964
997
|
return info
|
|
@@ -131,14 +131,28 @@ class AcclimationInfo:
|
|
|
131
131
|
def is_night_segment(points: list[SchedulePoint], minute_of_day: int) -> bool:
|
|
132
132
|
"""
|
|
133
133
|
True specifically when minute_of_day falls in the segment whose START
|
|
134
|
-
point
|
|
135
|
-
NIGHT (i.e. the
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
134
|
+
point IS flagged NIGHT (but not also SUNRISE) and whose END point is
|
|
135
|
+
ALSO flagged NIGHT (i.e. once actually into the night portion of the
|
|
136
|
+
schedule, up until the sunrise transition), or when minute_of_day
|
|
137
|
+
exactly matches a point that is itself flagged NIGHT. This is NOT
|
|
138
|
+
simply "is it currently night" as a general concept, but this
|
|
139
|
+
specific segment-boundary condition -- used specifically to decide
|
|
140
|
+
whether to substitute the lunar-phase reduction in place of the
|
|
141
141
|
normal schedule-intensity scalar.
|
|
142
|
+
|
|
143
|
+
Confirmed directly from raw smali bytecode (JADX's decompiled Java
|
|
144
|
+
for PointSchedule.getIntensitiesAtTime() carried its own "Code
|
|
145
|
+
duplicated" warning on this exact method -- a real signal, not
|
|
146
|
+
noise: its decompilation of this specific condition had the first
|
|
147
|
+
check inverted, i.e. `!point.has(NIGHT)` instead of the actual
|
|
148
|
+
`point.has(NIGHT)`. Confirmed via real hardware testing too: a
|
|
149
|
+
23:00[NIGHT,SUNSET]->23:27[NIGHT] segment produced a value matching
|
|
150
|
+
schedule-intensity-only scaling from this library, while the real
|
|
151
|
+
app displayed a much lower, lunar-consistent value at the same
|
|
152
|
+
moment -- mathematically impossible to explain by schedule-intensity
|
|
153
|
+
alone within that segment's raw-value range, which is what led back
|
|
154
|
+
to re-examining the raw bytecode instead of trusting the decompiled
|
|
155
|
+
Java a second time.
|
|
142
156
|
"""
|
|
143
157
|
if not points:
|
|
144
158
|
return False
|
|
@@ -160,7 +174,7 @@ def is_night_segment(points: list[SchedulePoint], minute_of_day: int) -> bool:
|
|
|
160
174
|
if t1 == t2:
|
|
161
175
|
return p1.has(SchedulePoint.FLAG_NIGHT)
|
|
162
176
|
return (
|
|
163
|
-
|
|
177
|
+
p1.has(SchedulePoint.FLAG_NIGHT)
|
|
164
178
|
and not p1.has(SchedulePoint.FLAG_SUNRISE)
|
|
165
179
|
and p2.has(SchedulePoint.FLAG_NIGHT)
|
|
166
180
|
)
|
|
@@ -71,9 +71,12 @@ async def test_get_device_summary_includes_all_pump_fields():
|
|
|
71
71
|
return responses[attr_id]
|
|
72
72
|
|
|
73
73
|
async def fake_get_attribute_raw_all(attr_id, index=0, count=1):
|
|
74
|
+
from mobius.frame import AttributeValue
|
|
74
75
|
if attr_id == C2Attribute.FirmwareVersion:
|
|
75
|
-
from mobius.frame import AttributeValue
|
|
76
76
|
return [AttributeValue(int(attr_id), index=1, values=[bytes([4, 0, 21])])]
|
|
77
|
+
if attr_id == C2Attribute.HardwareRevision:
|
|
78
|
+
# Color=White(2), Revision=3, ProductType=VorTech(3)
|
|
79
|
+
return [AttributeValue(int(attr_id), index=1, values=[bytes([2]), bytes([3]), bytes([3])])]
|
|
77
80
|
raise AssertionError(f"unmocked attribute (raw) in get_device_summary: {attr_id}")
|
|
78
81
|
|
|
79
82
|
device.get_attribute = fake_get_attribute
|
|
@@ -81,10 +84,12 @@ async def test_get_device_summary_includes_all_pump_fields():
|
|
|
81
84
|
summary = await device.get_device_summary()
|
|
82
85
|
|
|
83
86
|
# The actual regression check: every field from all 7 previously-
|
|
84
|
-
# unwired methods must be present, plus firmware_versions
|
|
87
|
+
# unwired methods must be present, plus firmware_versions and
|
|
88
|
+
# hardware_info (the same class of gap, caught again -- see
|
|
89
|
+
# get_device_summary()'s own "BUG NOTE" docstring).
|
|
85
90
|
for key in ("flow_range", "pump_override_mode", "battery_backup",
|
|
86
91
|
"boosted_battery", "group", "calibration", "maintenance",
|
|
87
|
-
"firmware_versions"):
|
|
92
|
+
"firmware_versions", "hardware_info"):
|
|
88
93
|
assert key in summary, f"{key!r} missing from get_device_summary() -- this is the exact bug this test guards against"
|
|
89
94
|
|
|
90
95
|
assert summary["support"] == "pump"
|
|
@@ -96,6 +101,7 @@ async def test_get_device_summary_includes_all_pump_fields():
|
|
|
96
101
|
assert summary["calibration"] is None # unsupported in this mock (pump, as expected)
|
|
97
102
|
assert summary["maintenance"].recommended_interval is None
|
|
98
103
|
assert summary["firmware_versions"] == {"Radio": "4.0.21"} # VorTechMP40wG3QD -> EcoTech label
|
|
104
|
+
assert summary["hardware_info"] == {"Color": "White", "Revision": 3, "ProductType": "VorTech"}
|
|
99
105
|
assert summary["device_time"].device_epoch == 1700000000
|
|
100
106
|
|
|
101
107
|
|
|
@@ -142,12 +148,13 @@ async def test_get_device_summary_includes_generic_fields_for_lights_too():
|
|
|
142
148
|
return responses[attr_id]
|
|
143
149
|
|
|
144
150
|
async def fake_get_attribute_raw_all(attr_id, index=0, count=1):
|
|
151
|
+
from mobius.frame import AttributeValue
|
|
145
152
|
if attr_id == C2Attribute.FirmwareVersion:
|
|
146
|
-
from mobius.frame import AttributeValue
|
|
147
153
|
return [AttributeValue(int(attr_id), index=3, values=[bytes([1, 0])])] # MainMicroOS
|
|
148
154
|
if attr_id == C2Attribute.SupportedColorChannels:
|
|
149
|
-
from mobius.frame import AttributeValue
|
|
150
155
|
return [AttributeValue(int(attr_id), index=0, values=[bytes([18])])] # RoyalBlue
|
|
156
|
+
if attr_id == C2Attribute.HardwareRevision:
|
|
157
|
+
return [AttributeValue(int(attr_id), index=4, values=[bytes([2])])] # RadioType=QCA4020(2)
|
|
151
158
|
raise AssertionError(f"unmocked attribute (raw): {attr_id}")
|
|
152
159
|
|
|
153
160
|
device.get_attribute = fake_get_attribute
|
|
@@ -159,6 +166,8 @@ async def test_get_device_summary_includes_generic_fields_for_lights_too():
|
|
|
159
166
|
assert "calibration" in summary
|
|
160
167
|
assert "firmware_versions" in summary
|
|
161
168
|
assert summary["firmware_versions"] == {"Product OS": "1.0"} # RadionXR15wG6Pro -> EcoTech label
|
|
169
|
+
assert "hardware_info" in summary
|
|
170
|
+
assert summary["hardware_info"] == {"RadioType": "QCA4020"}
|
|
162
171
|
assert "maintenance" in summary
|
|
163
172
|
# unlike the pump case, calibration IS populated here -- matches the
|
|
164
173
|
# confirmed real-world light-only gating
|
|
@@ -179,7 +179,7 @@ async def test_get_firmware_versions_empty_when_no_blocks_returned():
|
|
|
179
179
|
# --------------------------------------------------------------------------
|
|
180
180
|
|
|
181
181
|
@pytest.mark.asyncio
|
|
182
|
-
async def
|
|
182
|
+
async def test_get_hardware_info_decodes_confirmed_enum_fields():
|
|
183
183
|
device = MobiusDevice(_fake_ble_device())
|
|
184
184
|
|
|
185
185
|
async def fake_get_attribute_raw_all(attr_id, index=0, count=1):
|
|
@@ -193,7 +193,10 @@ async def test_get_hardware_info_returns_raw_bytes():
|
|
|
193
193
|
device.get_attribute_raw_all = fake_get_attribute_raw_all
|
|
194
194
|
info = await device.get_hardware_info()
|
|
195
195
|
|
|
196
|
-
|
|
196
|
+
# Color and ProductType are confirmed enums with confirmed labels --
|
|
197
|
+
# decoded, not raw bytes. Revision has no confirmed enum meaning --
|
|
198
|
+
# a plain integer, not formatted, but also not raw bytes anymore.
|
|
199
|
+
assert info == {"Color": "White", "Revision": 1, "ProductType": "VorTech"}
|
|
197
200
|
|
|
198
201
|
|
|
199
202
|
@pytest.mark.asyncio
|
|
@@ -214,7 +217,74 @@ async def test_get_hardware_info_merges_multiple_blocks():
|
|
|
214
217
|
device.get_attribute_raw_all = fake_get_attribute_raw_all
|
|
215
218
|
info = await device.get_hardware_info()
|
|
216
219
|
|
|
217
|
-
assert info == {"Color":
|
|
220
|
+
assert info == {"Color": "White", "Revision": 1, "ProductType": "VorTech"}
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
@pytest.mark.asyncio
|
|
224
|
+
async def test_get_hardware_info_decodes_all_four_enum_fields():
|
|
225
|
+
"""Confirms RadioType and MotorType decode too, not just Color/
|
|
226
|
+
ProductType -- all four confirmed enums, in one response."""
|
|
227
|
+
device = MobiusDevice(_fake_ble_device())
|
|
228
|
+
|
|
229
|
+
async def fake_get_attribute_raw_all(attr_id, index=0, count=1):
|
|
230
|
+
if attr_id == C2Attribute.HardwareRevision:
|
|
231
|
+
return [AttributeValue(
|
|
232
|
+
attr_id=int(attr_id), index=1,
|
|
233
|
+
# Color=White(2), Revision=5, ProductType=VorTech(3),
|
|
234
|
+
# RadioType=QCA4020(2), MotorType=VorTechMP40G3(3)
|
|
235
|
+
values=[bytes([2]), bytes([5]), bytes([3]), bytes([2]), bytes([3])],
|
|
236
|
+
)]
|
|
237
|
+
raise AssertionError(f"unexpected attribute: {attr_id}")
|
|
238
|
+
|
|
239
|
+
device.get_attribute_raw_all = fake_get_attribute_raw_all
|
|
240
|
+
info = await device.get_hardware_info()
|
|
241
|
+
|
|
242
|
+
assert info == {
|
|
243
|
+
"Color": "White", "Revision": 5, "ProductType": "VorTech",
|
|
244
|
+
"RadioType": "QCA4020", "MotorType": "VorTech MP40 G3",
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
@pytest.mark.asyncio
|
|
249
|
+
async def test_get_hardware_info_falls_back_gracefully_for_unrecognized_enum_value():
|
|
250
|
+
"""A value outside this library's confirmed enum coverage (e.g.
|
|
251
|
+
firmware newer than what's been confirmed) must not raise -- falls
|
|
252
|
+
back to a clearly-marked "Unknown (N)" string rather than crashing
|
|
253
|
+
the whole fetch."""
|
|
254
|
+
device = MobiusDevice(_fake_ble_device())
|
|
255
|
+
|
|
256
|
+
async def fake_get_attribute_raw_all(attr_id, index=0, count=1):
|
|
257
|
+
if attr_id == C2Attribute.HardwareRevision:
|
|
258
|
+
return [AttributeValue(
|
|
259
|
+
attr_id=int(attr_id), index=1,
|
|
260
|
+
values=[bytes([250])], # Color -- not a recognized Color value
|
|
261
|
+
)]
|
|
262
|
+
raise AssertionError(f"unexpected attribute: {attr_id}")
|
|
263
|
+
|
|
264
|
+
device.get_attribute_raw_all = fake_get_attribute_raw_all
|
|
265
|
+
info = await device.get_hardware_info()
|
|
266
|
+
|
|
267
|
+
assert info == {"Color": "Unknown (250)"}
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
@pytest.mark.asyncio
|
|
271
|
+
async def test_get_hardware_info_obfuscated_motor_type_labels_decode_correctly():
|
|
272
|
+
"""Confirms the base64-decoded MotorType labels (Nero 7/Orbit 2/
|
|
273
|
+
Orbit 4/Axis 20/40/90/200) work, not just the plain-literal ones."""
|
|
274
|
+
device = MobiusDevice(_fake_ble_device())
|
|
275
|
+
|
|
276
|
+
async def fake_get_attribute_raw_all(attr_id, index=0, count=1):
|
|
277
|
+
if attr_id == C2Attribute.HardwareRevision:
|
|
278
|
+
return [AttributeValue(
|
|
279
|
+
attr_id=int(attr_id), index=5,
|
|
280
|
+
values=[bytes([18])], # MotorType=Turtle1(18) -> "Axis 20"
|
|
281
|
+
)]
|
|
282
|
+
raise AssertionError(f"unexpected attribute: {attr_id}")
|
|
283
|
+
|
|
284
|
+
device.get_attribute_raw_all = fake_get_attribute_raw_all
|
|
285
|
+
info = await device.get_hardware_info()
|
|
286
|
+
|
|
287
|
+
assert info == {"MotorType": "Axis 20"}
|
|
218
288
|
|
|
219
289
|
|
|
220
290
|
@pytest.mark.asyncio
|
|
@@ -18,17 +18,21 @@ def _fake_ble_device():
|
|
|
18
18
|
|
|
19
19
|
|
|
20
20
|
def _night_schedule():
|
|
21
|
-
"""A 2-point schedule:
|
|
22
|
-
|
|
23
|
-
|
|
21
|
+
"""A 2-point schedule: 22:00 (NIGHT, MoonlightWhite=0) -> 23:00 (NIGHT,
|
|
22
|
+
MoonlightWhite=1000) -- a genuine night-to-night segment (confirmed
|
|
23
|
+
from raw smali bytecode: is_night_segment() requires the START point
|
|
24
|
+
to ALREADY be night-flagged, not the dusk-transition segment leading
|
|
25
|
+
into it -- see that function's own docstring for the full trace)."""
|
|
26
|
+
point_a = (
|
|
27
|
+
(1320).to_bytes(2, "little") + bytes([SchedulePoint.FLAG_ACTIVE | SchedulePoint.FLAG_NIGHT])
|
|
24
28
|
+ bytes([31]) + (0).to_bytes(2, "little") # MoonlightWhite(31) = 0
|
|
25
29
|
)
|
|
26
|
-
|
|
27
|
-
(
|
|
30
|
+
point_b = (
|
|
31
|
+
(1380).to_bytes(2, "little")
|
|
28
32
|
+ bytes([SchedulePoint.FLAG_ACTIVE | SchedulePoint.FLAG_NIGHT])
|
|
29
33
|
+ bytes([31]) + (1000).to_bytes(2, "little")
|
|
30
34
|
)
|
|
31
|
-
return [
|
|
35
|
+
return [point_a, point_b]
|
|
32
36
|
|
|
33
37
|
|
|
34
38
|
def _day_schedule():
|
|
@@ -51,14 +55,14 @@ async def test_night_segment_with_lunar_enabled_uses_lunar_reduction():
|
|
|
51
55
|
raise AssertionError(f"unexpected attribute during night+lunar test: {attr_id}")
|
|
52
56
|
|
|
53
57
|
device.get_attribute = fake_get_attribute
|
|
54
|
-
#
|
|
55
|
-
# (deliberately not a full-moon
|
|
56
|
-
# distinguishable from the "no lunar"
|
|
57
|
-
# regardless of date)
|
|
58
|
+
# 22:30, inside the night-to-night segment (22:00[NIGHT]->23:00[NIGHT]);
|
|
59
|
+
# real NEW moon date -> reduction 0.0 (deliberately not a full-moon
|
|
60
|
+
# date, so this test is actually distinguishable from the "no lunar"
|
|
61
|
+
# case below, which stays at scalar 1.0 regardless of date)
|
|
58
62
|
now = datetime.datetime(2026, 1, 18, 20, 0)
|
|
59
|
-
result = await device.get_current_light_intensities(minute_of_day=
|
|
63
|
+
result = await device.get_current_light_intensities(minute_of_day=1350, now=now)
|
|
60
64
|
|
|
61
|
-
# interpolated MoonlightWhite at
|
|
65
|
+
# interpolated MoonlightWhite at 22:30 (50% through 22:00->23:00) = 500,
|
|
62
66
|
# times lunar reduction 0.0 (new moon) = 0.0
|
|
63
67
|
assert result[VisualID.MoonlightWhite] == pytest.approx(0.0)
|
|
64
68
|
# The actual diagnostic fields this test exists to cover -- added
|
|
@@ -85,7 +89,7 @@ async def test_night_segment_without_lunar_uses_full_scalar():
|
|
|
85
89
|
# same new-moon date as the test above -- if lunar reduction were being
|
|
86
90
|
# applied here too, this would also read ~0.0; confirms it isn't.
|
|
87
91
|
now = datetime.datetime(2026, 1, 18, 20, 0)
|
|
88
|
-
result = await device.get_current_light_intensities(minute_of_day=
|
|
92
|
+
result = await device.get_current_light_intensities(minute_of_day=1350, now=now)
|
|
89
93
|
|
|
90
94
|
# scalar = 1.0 (no reduction) -> raw interpolated value (500) unchanged
|
|
91
95
|
assert result[VisualID.MoonlightWhite] == pytest.approx(500.0)
|
|
@@ -118,13 +118,31 @@ def _point(minute, flags):
|
|
|
118
118
|
return SchedulePoint(minute, flags, LightPrimitive({}))
|
|
119
119
|
|
|
120
120
|
|
|
121
|
-
def
|
|
121
|
+
def test_dusk_transition_segment_itself_is_not_night_segment():
|
|
122
|
+
"""Confirmed from raw smali bytecode (not the decompiled Java, which
|
|
123
|
+
had this exact condition inverted -- see is_night_segment()'s own
|
|
124
|
+
docstring): the segment transitioning INTO night (start point not
|
|
125
|
+
yet night-flagged) is explicitly NOT considered a night segment --
|
|
126
|
+
only segments where the START point is ALREADY night-flagged are."""
|
|
122
127
|
# 18:00 (1080) not-night/not-sunrise -> 22:00 (1320) night
|
|
123
128
|
points = [
|
|
124
129
|
_point(1080, SchedulePoint.FLAG_ACTIVE),
|
|
125
130
|
_point(1320, SchedulePoint.FLAG_ACTIVE | SchedulePoint.FLAG_NIGHT),
|
|
126
131
|
]
|
|
127
|
-
assert is_night_segment(points, 1200) # 20:00, inside the dusk segment
|
|
132
|
+
assert not is_night_segment(points, 1200) # 20:00, inside the dusk-transition segment
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_segment_between_two_night_flagged_points_is_a_night_segment():
|
|
136
|
+
"""The actual real-world scenario this bug was found from: a light
|
|
137
|
+
showing lunar-consistent values in the app while this library
|
|
138
|
+
computed schedule-intensity-only values at the exact same moment, in
|
|
139
|
+
a segment where BOTH endpoints were night-flagged
|
|
140
|
+
(23:00[NIGHT,SUNSET] -> 23:27[NIGHT])."""
|
|
141
|
+
points = [
|
|
142
|
+
_point(1380, SchedulePoint.FLAG_ACTIVE | SchedulePoint.FLAG_NIGHT), # 23:00
|
|
143
|
+
_point(1407, SchedulePoint.FLAG_ACTIVE | SchedulePoint.FLAG_NIGHT), # 23:27
|
|
144
|
+
]
|
|
145
|
+
assert is_night_segment(points, 1395) # 23:15, well within this segment
|
|
128
146
|
|
|
129
147
|
|
|
130
148
|
def test_segment_not_flagged_night_is_false():
|
|
@@ -144,10 +162,28 @@ def test_sunrise_flagged_start_point_is_not_dusk_segment():
|
|
|
144
162
|
assert not is_night_segment(points, 1200)
|
|
145
163
|
|
|
146
164
|
|
|
147
|
-
def
|
|
165
|
+
def test_exact_match_on_start_of_a_night_qualifying_segment():
|
|
166
|
+
"""is_night_segment() is fundamentally about which SEGMENT the query
|
|
167
|
+
time falls into, not "is this point itself night-flagged" in
|
|
168
|
+
isolation -- a query time exactly at a segment's start still
|
|
169
|
+
evaluates that segment's own start/end flags normally, not some
|
|
170
|
+
separate "exact match" special case (that only exists for a
|
|
171
|
+
genuinely zero-width segment, i.e. two points sharing the same
|
|
172
|
+
time -- see the t1==t2 branch)."""
|
|
148
173
|
points = [
|
|
149
174
|
_point(1320, SchedulePoint.FLAG_ACTIVE | SchedulePoint.FLAG_NIGHT),
|
|
150
|
-
_point(
|
|
175
|
+
_point(1440, SchedulePoint.FLAG_ACTIVE | SchedulePoint.FLAG_NIGHT), # wraps to 0 (midnight)
|
|
176
|
+
]
|
|
177
|
+
assert is_night_segment(points, 1320) # exactly at the segment's own start
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def test_exact_match_on_a_zero_width_segment():
|
|
181
|
+
"""The genuine "exact match" special case -- two points sharing the
|
|
182
|
+
same time (t1==t2), where the segment's own start point flag decides
|
|
183
|
+
it directly, no end-point/sunrise check involved."""
|
|
184
|
+
points = [
|
|
185
|
+
_point(1320, SchedulePoint.FLAG_ACTIVE | SchedulePoint.FLAG_NIGHT),
|
|
186
|
+
_point(1320, SchedulePoint.FLAG_ACTIVE | SchedulePoint.FLAG_NIGHT),
|
|
151
187
|
]
|
|
152
188
|
assert is_night_segment(points, 1320)
|
|
153
189
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/10-known-gaps-and-open-questions.md
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|