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.
Files changed (63) hide show
  1. python_mobius-0.3.0/.forgejo/scripts/extract_changelog_section.py +42 -0
  2. python_mobius-0.3.0/.forgejo/workflows/release.yml +110 -0
  3. {python_mobius-0.2.1 → python_mobius-0.3.0}/CHANGELOG.md +56 -0
  4. {python_mobius-0.2.1 → python_mobius-0.3.0}/PKG-INFO +1 -1
  5. {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/06-light-schedule.md +27 -9
  6. {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/11-device-status-attributes.md +31 -11
  7. {python_mobius-0.2.1 → python_mobius-0.3.0}/pyproject.toml +1 -1
  8. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/__init__.py +5 -1
  9. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/constants.py +172 -5
  10. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/device.py +45 -12
  11. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/modifiers.py +22 -8
  12. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_device_summary.py +14 -5
  13. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_firmware_versions.py +73 -3
  14. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_light_intensity_branching.py +17 -13
  15. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_modifiers.py +40 -4
  16. {python_mobius-0.2.1 → python_mobius-0.3.0}/.gitignore +0 -0
  17. {python_mobius-0.2.1 → python_mobius-0.3.0}/LICENSE +0 -0
  18. {python_mobius-0.2.1 → python_mobius-0.3.0}/README.md +0 -0
  19. {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/00-overview.md +0 -0
  20. {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/01-ble-transport.md +0 -0
  21. {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/02-framing-and-crc.md +0 -0
  22. {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/03-attributes-and-opcodes.md +0 -0
  23. {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/04-device-identity.md +0 -0
  24. {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/05-scenes.md +0 -0
  25. {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/07-pump-schedule.md +0 -0
  26. {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/08-manufacturer-data.md +0 -0
  27. {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/09-thread-coap-relay.md +0 -0
  28. {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/10-known-gaps-and-open-questions.md +0 -0
  29. {python_mobius-0.2.1 → python_mobius-0.3.0}/documentation/12-device-identity-and-address-stability.md +0 -0
  30. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/cli.py +0 -0
  31. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/coap.py +0 -0
  32. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/crc.py +0 -0
  33. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/device_status.py +0 -0
  34. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/discovery.py +0 -0
  35. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/frame.py +0 -0
  36. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/manufacturer.py +0 -0
  37. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/mesh_address.py +0 -0
  38. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/power.py +0 -0
  39. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/pump_status.py +0 -0
  40. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/relay.py +0 -0
  41. {python_mobius-0.2.1 → python_mobius-0.3.0}/src/mobius/schedule.py +0 -0
  42. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_coap.py +0 -0
  43. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_coap_indication_handling.py +0 -0
  44. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_connection_cleanup.py +0 -0
  45. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_device_status.py +0 -0
  46. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_device_time.py +0 -0
  47. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_frame.py +0 -0
  48. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_frame_sniffer.py +0 -0
  49. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_hyperdrive_device.py +0 -0
  50. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_insolation_detection.py +0 -0
  51. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_light_schedule.py +0 -0
  52. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_manufacturer.py +0 -0
  53. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_manufacturer_lookup.py +0 -0
  54. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_mesh_address.py +0 -0
  55. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_mesh_discovery.py +0 -0
  56. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_mesh_discovery_direct_connect.py +0 -0
  57. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_power.py +0 -0
  58. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_pump_schedule.py +0 -0
  59. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_pump_status.py +0 -0
  60. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_relay.py +0 -0
  61. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_schedule_intensity_scalar.py +0 -0
  62. {python_mobius-0.2.1 → python_mobius-0.3.0}/tests/test_serial_decoding.py +0 -0
  63. {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.2.1
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 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.
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 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.
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`, 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.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.2.1"
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. 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
@@ -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). Unlike firmware
832
- versions, no dot-joined-string display convention is confirmed for
833
- these fields (Color/Revision/ProductType/RadioType/MotorType/
834
- Segments read more like small integer/enum codes than version
835
- numbers) -- returns {HardwareInfo_name: raw_bytes}, not formatted,
836
- until that's confirmed against real hardware.
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
- result[hw_info.name] = value_bytes
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", and
877
- "maintenance" (see get_group_info()/get_calibration_info()/
878
- get_maintenance_info() -- calibration in particular is expected to
879
- only actually populate on lights, not pumps; see
880
- documentation/11-device-status-attributes.md).
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 is NOT flagged NIGHT or SUNRISE and whose END point IS flagged
135
- NIGHT (i.e. the dusk-transition-into-night segment), or when
136
- minute_of_day exactly matches a point that is itself flagged NIGHT.
137
- This is NOT simply "is it currently night" -- that's genuinely the
138
- Java source's own semantics (confirmed by reading
139
- PointSchedule.getIntensitiesAtTime() directly), used specifically to
140
- decide whether to substitute the lunar-phase reduction in place of the
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
- not p1.has(SchedulePoint.FLAG_NIGHT)
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 test_get_hardware_info_returns_raw_bytes():
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
- assert info == {"Color": bytes([2]), "Revision": bytes([1]), "ProductType": bytes([3])}
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": bytes([2]), "Revision": bytes([1]), "ProductType": bytes([3])}
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: 18:00 (day, MoonlightWhite=0) -> 22:00 (NIGHT, MoonlightWhite=1000)."""
22
- day_point = (
23
- (1080).to_bytes(2, "little") + bytes([SchedulePoint.FLAG_ACTIVE])
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
- night_point = (
27
- (1320).to_bytes(2, "little")
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 [day_point, night_point]
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
- # 20:00, inside the dusk-to-night segment; real NEW moon date -> reduction 0.0
55
- # (deliberately not a full-moon date, so this test is actually
56
- # distinguishable from the "no lunar" case below, which stays at scalar 1.0
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=1200, now=now)
63
+ result = await device.get_current_light_intensities(minute_of_day=1350, now=now)
60
64
 
61
- # interpolated MoonlightWhite at 20:00 (50% through 18:00->22:00) = 500,
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=1200, now=now)
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 test_dusk_to_night_segment_detected():
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 test_exact_match_on_night_flagged_point():
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(480, SchedulePoint.FLAG_ACTIVE),
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