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.
Files changed (64) hide show
  1. python_mobius-0.3.1/.forgejo/scripts/extract_changelog_section.py +42 -0
  2. python_mobius-0.3.1/.forgejo/workflows/release.yml +110 -0
  3. {python_mobius-0.2.1 → python_mobius-0.3.1}/CHANGELOG.md +85 -0
  4. {python_mobius-0.2.1 → python_mobius-0.3.1}/PKG-INFO +1 -1
  5. {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/06-light-schedule.md +70 -13
  6. {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/11-device-status-attributes.md +31 -11
  7. {python_mobius-0.2.1 → python_mobius-0.3.1}/pyproject.toml +1 -1
  8. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/__init__.py +5 -1
  9. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/constants.py +172 -5
  10. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/device.py +45 -12
  11. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/modifiers.py +22 -8
  12. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/schedule.py +50 -1
  13. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_device_summary.py +14 -5
  14. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_firmware_versions.py +73 -3
  15. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_light_intensity_branching.py +17 -13
  16. python_mobius-0.3.1/tests/test_light_schedule.py +135 -0
  17. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_modifiers.py +40 -4
  18. python_mobius-0.2.1/tests/test_light_schedule.py +0 -58
  19. {python_mobius-0.2.1 → python_mobius-0.3.1}/.gitignore +0 -0
  20. {python_mobius-0.2.1 → python_mobius-0.3.1}/LICENSE +0 -0
  21. {python_mobius-0.2.1 → python_mobius-0.3.1}/README.md +0 -0
  22. {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/00-overview.md +0 -0
  23. {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/01-ble-transport.md +0 -0
  24. {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/02-framing-and-crc.md +0 -0
  25. {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/03-attributes-and-opcodes.md +0 -0
  26. {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/04-device-identity.md +0 -0
  27. {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/05-scenes.md +0 -0
  28. {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/07-pump-schedule.md +0 -0
  29. {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/08-manufacturer-data.md +0 -0
  30. {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/09-thread-coap-relay.md +0 -0
  31. {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/10-known-gaps-and-open-questions.md +0 -0
  32. {python_mobius-0.2.1 → python_mobius-0.3.1}/documentation/12-device-identity-and-address-stability.md +0 -0
  33. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/cli.py +0 -0
  34. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/coap.py +0 -0
  35. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/crc.py +0 -0
  36. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/device_status.py +0 -0
  37. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/discovery.py +0 -0
  38. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/frame.py +0 -0
  39. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/manufacturer.py +0 -0
  40. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/mesh_address.py +0 -0
  41. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/power.py +0 -0
  42. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/pump_status.py +0 -0
  43. {python_mobius-0.2.1 → python_mobius-0.3.1}/src/mobius/relay.py +0 -0
  44. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_coap.py +0 -0
  45. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_coap_indication_handling.py +0 -0
  46. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_connection_cleanup.py +0 -0
  47. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_device_status.py +0 -0
  48. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_device_time.py +0 -0
  49. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_frame.py +0 -0
  50. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_frame_sniffer.py +0 -0
  51. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_hyperdrive_device.py +0 -0
  52. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_insolation_detection.py +0 -0
  53. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_manufacturer.py +0 -0
  54. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_manufacturer_lookup.py +0 -0
  55. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_mesh_address.py +0 -0
  56. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_mesh_discovery.py +0 -0
  57. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_mesh_discovery_direct_connect.py +0 -0
  58. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_power.py +0 -0
  59. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_pump_schedule.py +0 -0
  60. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_pump_status.py +0 -0
  61. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_relay.py +0 -0
  62. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_schedule_intensity_scalar.py +0 -0
  63. {python_mobius-0.2.1 → python_mobius-0.3.1}/tests/test_serial_decoding.py +0 -0
  64. {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.2.1
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. For each channel present in either bracketing point, linearly
85
- interpolate: `value(t) = v1 + (v2 - v1) * (t - t1) / (t2 - t1)`.
86
- 4. Channels absent from a point default to `0` (off).
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, and midnight-wraparound blending -- see
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 specifically during the
114
- **dusk-to-night segment** of the schedule (see `is_night_segment()` below),
115
- and only when `LunarPhasesEnabled` (attribute 907) is set on the device --
116
- otherwise that segment just uses a flat `1.0` (full/unreduced) scalar,
117
- matching the app's own fallback.
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 the segment whose
122
- start point is NOT flagged `NIGHT` or `SUNRISE` and whose end point IS
123
- flagged `NIGHT` (the dusk-transition segment), or when the query time
124
- exactly matches a `NIGHT`-flagged point.
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`, but **no display-formatting convention
174
- is confirmed** for those fields (`Color`/`Revision`/`ProductType`/
175
- `RadioType`/`MotorType`/`Segments` read more like small integer/enum
176
- codes than version numbers) -- returns raw bytes per field rather than
177
- guessing a format.
178
-
179
- Both are included automatically in `get_device_summary()`'s
180
- `"firmware_versions"` field (using the confirmed EcoTech labels, since
181
- `get_device_summary()` already has the device's `Model` on hand).
182
- `get_hardware_info()` is not currently wired into the summary -- call it
183
- directly if you want it.
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.2.1"
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.2.1"
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. Unlike FirmwareVersion, no dot-joined-string
496
- display convention is confirmed for these fields (Color/Revision/
497
- ProductType/RadioType/MotorType/Segments read more like small
498
- integer/enum codes than version numbers) -- returned as raw bytes,
499
- not formatted, until that's confirmed.
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