python-mobius 0.3.0__tar.gz → 0.3.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {python_mobius-0.3.0 → python_mobius-0.3.1}/CHANGELOG.md +29 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/PKG-INFO +1 -1
- {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/06-light-schedule.md +43 -4
- {python_mobius-0.3.0 → python_mobius-0.3.1}/pyproject.toml +1 -1
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/__init__.py +1 -1
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/schedule.py +50 -1
- python_mobius-0.3.1/tests/test_light_schedule.py +135 -0
- python_mobius-0.3.0/tests/test_light_schedule.py +0 -58
- {python_mobius-0.3.0 → python_mobius-0.3.1}/.forgejo/scripts/extract_changelog_section.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/.forgejo/workflows/release.yml +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/.gitignore +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/LICENSE +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/README.md +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/00-overview.md +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/01-ble-transport.md +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/02-framing-and-crc.md +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/03-attributes-and-opcodes.md +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/04-device-identity.md +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/05-scenes.md +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/07-pump-schedule.md +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/08-manufacturer-data.md +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/09-thread-coap-relay.md +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/10-known-gaps-and-open-questions.md +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/11-device-status-attributes.md +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/12-device-identity-and-address-stability.md +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/cli.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/coap.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/constants.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/crc.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/device.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/device_status.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/discovery.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/frame.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/manufacturer.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/mesh_address.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/modifiers.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/power.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/pump_status.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/relay.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_coap.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_coap_indication_handling.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_connection_cleanup.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_device_status.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_device_summary.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_device_time.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_firmware_versions.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_frame.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_frame_sniffer.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_hyperdrive_device.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_insolation_detection.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_light_intensity_branching.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_manufacturer.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_manufacturer_lookup.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_mesh_address.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_mesh_discovery.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_mesh_discovery_direct_connect.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_modifiers.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_power.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_pump_schedule.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_pump_status.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_relay.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_schedule_intensity_scalar.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_serial_decoding.py +0 -0
- {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_serial_identity.py +0 -0
|
@@ -1,5 +1,34 @@
|
|
|
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
|
+
|
|
3
32
|
## 0.3.0
|
|
4
33
|
|
|
5
34
|
- **Fixed a real, high-impact bug in `is_night_segment()`: the condition
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: python-mobius
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)
|
|
5
5
|
Project-URL: Homepage, https://code.r3pek.org/r3pek/python-mobius
|
|
6
6
|
Project-URL: Documentation, https://code.r3pek.org/r3pek/python-mobius/src/branch/main/documentation
|
|
@@ -81,12 +81,51 @@ Ported from `PointSchedule.getIntensitiesAtTime()`:
|
|
|
81
81
|
2. Find the two points that bracket the target minute-of-day, wrapping
|
|
82
82
|
around midnight (the point after the last one is treated as being on
|
|
83
83
|
"the next day," i.e. its time + 1440).
|
|
84
|
-
3.
|
|
85
|
-
|
|
86
|
-
|
|
84
|
+
3. **`Brightness` (`VisualID.Brightness`) is a master dimmer, applied to
|
|
85
|
+
EACH bracketing point separately, before interpolating between them**
|
|
86
|
+
-- confirmed from raw smali (`LightPrimitive.flatten()`, called on
|
|
87
|
+
both point copies in `getIntensitiesAtTime()` before the
|
|
88
|
+
interpolation loop -- JADX's decompiled Java for this class carried
|
|
89
|
+
its own "Code duplicated" warning, the same signal that led to
|
|
90
|
+
finding the `is_night_segment()` bug, so this was verified against
|
|
91
|
+
bytecode directly rather than trusted from the decompile). For each
|
|
92
|
+
point independently: every channel except `Brightness` itself is
|
|
93
|
+
multiplied by that same point's own `Brightness` value / 1000. This
|
|
94
|
+
was a real, confirmed-missing step for a while -- every channel
|
|
95
|
+
(moonlight included) was being interpolated and returned without
|
|
96
|
+
ever being scaled by `Brightness` at all, found via real hardware
|
|
97
|
+
testing (a light showing ~1% in the app's own display came back as
|
|
98
|
+
~25% from this library at the same moment, with `Brightness` itself
|
|
99
|
+
reading ~3.3% at that point in its own ramp).
|
|
100
|
+
4. For each channel present in either bracketing point, linearly
|
|
101
|
+
interpolate between the (already `Brightness`-scaled, for
|
|
102
|
+
non-`Brightness` channels) endpoint values:
|
|
103
|
+
`value(t) = v1 + (v2 - v1) * (t - t1) / (t2 - t1)`.
|
|
104
|
+
**Flattening happens per-endpoint before this step, not
|
|
105
|
+
interpolate-raw-then-multiply-by-a-single-`Brightness`-value after
|
|
106
|
+
it** -- these are NOT mathematically equivalent whenever `Brightness`
|
|
107
|
+
itself changes across the segment (which it usually does, e.g.
|
|
108
|
+
ramping during a dusk/dawn transition).
|
|
109
|
+
5. Channels absent from a point default to `0` (off), except
|
|
110
|
+
`Brightness` itself: a point missing that channel entirely is treated
|
|
111
|
+
as a no-op 1000 (100%) multiplier for that point, matching
|
|
112
|
+
`flatten()`'s own `visuals().contains(Brightness)` guard (skips
|
|
113
|
+
scaling entirely rather than treating an absent value as 0, which
|
|
114
|
+
would zero out every other channel).
|
|
115
|
+
6. `Brightness` itself is returned as the plain raw interpolated value
|
|
116
|
+
between the two points' own `Brightness` levels -- NOT flattened to
|
|
117
|
+
1000 the way the real `flatten()` call does internally to the
|
|
118
|
+
primitive it's applied to. Unlike the app's own dashboard (which
|
|
119
|
+
doesn't display `Brightness` as its own value at all, only as the
|
|
120
|
+
multiplier baked into every other channel), this library's callers
|
|
121
|
+
(e.g. a dedicated `Brightness` sensor) want the actual current
|
|
122
|
+
master-dimmer level itself.
|
|
87
123
|
|
|
88
124
|
Verified against a synthetic two-point schedule (exact endpoint values,
|
|
89
|
-
midpoint blending,
|
|
125
|
+
midpoint blending, midnight-wraparound blending, and the `Brightness`
|
|
126
|
+
master-dimmer behavior above, specifically including a case designed to
|
|
127
|
+
mathematically distinguish flatten-then-interpolate from the wrong
|
|
128
|
+
interpolate-then-multiply alternative -- see
|
|
90
129
|
`tests/test_light_schedule.py`) and against a real 9-point schedule
|
|
91
130
|
fetched live, which produces a coherent daily light-intensity curve (dim
|
|
92
131
|
moonlight overnight → ramp to full blues/UV midday → wind back down).
|
|
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "python-mobius"
|
|
7
|
-
version = "0.3.
|
|
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" }
|
|
@@ -88,6 +88,41 @@ def interpolate_light_schedule(points: list[SchedulePoint], minute_of_day: int)
|
|
|
88
88
|
schedule points and a target time (0-1439, minutes since midnight),
|
|
89
89
|
returns {VisualID: intensity_permille} by linearly interpolating between
|
|
90
90
|
the two points that bracket that time, wrapping around midnight.
|
|
91
|
+
|
|
92
|
+
Applies LightPrimitive.flatten()'s confirmed semantics (see that
|
|
93
|
+
method and its two call sites in getIntensitiesAtTime() in the real
|
|
94
|
+
smali -- JADX's decompiled Java for this class carried its own "Code
|
|
95
|
+
duplicated" warning, the same signal that led to finding the
|
|
96
|
+
is_night_segment() bug, so this was re-verified against raw bytecode
|
|
97
|
+
rather than trusted from the decompile alone): the "Brightness"
|
|
98
|
+
channel (VisualID.Brightness) is a master dimmer that multiplies
|
|
99
|
+
every OTHER channel -- confirmed via real hardware testing that this
|
|
100
|
+
was missing entirely: MoonlightBlue/White (and every other color
|
|
101
|
+
channel) were computed without ever being scaled by Brightness,
|
|
102
|
+
which the real app clearly does (a light showing ~1% in the app's
|
|
103
|
+
own "Current Intensities" display came back as ~25% from this
|
|
104
|
+
library at the same moment, with Brightness itself reading ~3.3% at
|
|
105
|
+
that point in its own ramp -- consistent with the missing
|
|
106
|
+
multiplication, not a lunar or scheduling bug).
|
|
107
|
+
|
|
108
|
+
Critically, this flattening happens to EACH bracketing point
|
|
109
|
+
SEPARATELY (each point's own non-Brightness channels multiplied by
|
|
110
|
+
that SAME point's own Brightness value) BEFORE interpolating between
|
|
111
|
+
the two already-flattened points -- not interpolate raw values first
|
|
112
|
+
and apply a single Brightness value at the end. These are NOT
|
|
113
|
+
mathematically equivalent whenever Brightness itself changes across
|
|
114
|
+
the segment (which it usually does, e.g. ramping 0->1000 during a
|
|
115
|
+
dusk/dawn transition) -- confirmed by the smali calling flatten() on
|
|
116
|
+
both point copies before the interpolation loop, not after it.
|
|
117
|
+
|
|
118
|
+
"Brightness" itself is deliberately NOT flattened to 1000 in the
|
|
119
|
+
returned dict the way the real flatten() call does internally --
|
|
120
|
+
unlike the app's own dashboard (which doesn't display Brightness as
|
|
121
|
+
its own value at all, only as the multiplier baked into every other
|
|
122
|
+
channel), this library's callers (e.g. a dedicated Brightness
|
|
123
|
+
sensor) want the actual current master-dimmer level itself, not a
|
|
124
|
+
constant 100%. Returned as the plain raw interpolation between the
|
|
125
|
+
two points' own Brightness values, same as before this fix.
|
|
91
126
|
"""
|
|
92
127
|
if not points:
|
|
93
128
|
return {}
|
|
@@ -109,12 +144,26 @@ def interpolate_light_schedule(points: list[SchedulePoint], minute_of_day: int)
|
|
|
109
144
|
if t1 <= m <= t2:
|
|
110
145
|
span = (t2 - t1) or 1
|
|
111
146
|
frac = (m - t1) / span
|
|
147
|
+
|
|
148
|
+
# Each point's own Brightness value, defaulting to 1000 (a
|
|
149
|
+
# no-op multiplier) if that point doesn't have a Brightness
|
|
150
|
+
# channel at all -- matches flatten()'s own
|
|
151
|
+
# visuals().contains(Brightness) guard, which skips scaling
|
|
152
|
+
# entirely rather than treating an absent channel as 0.
|
|
153
|
+
b1 = p1.light.get(VisualID.Brightness) if VisualID.Brightness in p1.light.channels else 1000
|
|
154
|
+
b2 = p2.light.get(VisualID.Brightness) if VisualID.Brightness in p2.light.channels else 1000
|
|
155
|
+
|
|
112
156
|
result = {}
|
|
113
157
|
all_channels = set(p1.light.channels) | set(p2.light.channels)
|
|
114
158
|
for ch in all_channels:
|
|
115
159
|
v1 = p1.light.get(ch)
|
|
116
160
|
v2 = p2.light.get(ch)
|
|
117
|
-
|
|
161
|
+
if ch == VisualID.Brightness:
|
|
162
|
+
result[ch] = v1 + (v2 - v1) * frac
|
|
163
|
+
else:
|
|
164
|
+
flattened1 = v1 * b1 / 1000.0
|
|
165
|
+
flattened2 = v2 * b2 / 1000.0
|
|
166
|
+
result[ch] = flattened1 + (flattened2 - flattened1) * frac
|
|
118
167
|
return result
|
|
119
168
|
return {}
|
|
120
169
|
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Light schedule parsing/interpolation tests. The interpolation algorithm and
|
|
3
|
+
9-point schedule shape here mirror what was captured live from two real
|
|
4
|
+
Radion XR15 lights -- see documentation/06-light-schedule.md.
|
|
5
|
+
"""
|
|
6
|
+
from mobius import (
|
|
7
|
+
LightPrimitive, SchedulePoint, interpolate_light_schedule, VisualID,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def test_light_primitive_parses_channel_triplets():
|
|
12
|
+
# RoyalBlue(18) = 500
|
|
13
|
+
data = bytes([18]) + (500).to_bytes(2, "little")
|
|
14
|
+
lp = LightPrimitive.parse(data)
|
|
15
|
+
assert lp.get(VisualID.RoyalBlue) == 500
|
|
16
|
+
assert lp.get(VisualID.Blue) == 0 # unset channel defaults to 0
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def test_schedule_point_parses_time_flags_and_primitive():
|
|
20
|
+
raw_channel = bytes([18]) + (500).to_bytes(2, "little")
|
|
21
|
+
raw_point = (600).to_bytes(2, "little") + bytes([1]) + raw_channel
|
|
22
|
+
sp = SchedulePoint.parse(raw_point)
|
|
23
|
+
assert sp.time_minutes == 600
|
|
24
|
+
assert sp.flags == 1
|
|
25
|
+
assert sp.light.get(VisualID.RoyalBlue) == 500
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def test_inactive_point_returns_none():
|
|
29
|
+
raw_channel = bytes([18]) + (500).to_bytes(2, "little")
|
|
30
|
+
raw_point = (600).to_bytes(2, "little") + bytes([0]) + raw_channel # flags=0, not ACTIVE
|
|
31
|
+
assert SchedulePoint.parse(raw_point) is None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def _make_point(minute, royal_blue):
|
|
35
|
+
return SchedulePoint(minute, 1, LightPrimitive({VisualID.RoyalBlue: royal_blue}))
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def test_interpolation_at_exact_points():
|
|
39
|
+
points = [_make_point(480, 0), _make_point(720, 1000)]
|
|
40
|
+
assert interpolate_light_schedule(points, 480)[VisualID.RoyalBlue] == 0.0
|
|
41
|
+
assert interpolate_light_schedule(points, 720)[VisualID.RoyalBlue] == 1000.0
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def test_interpolation_halfway_between_points():
|
|
45
|
+
points = [_make_point(480, 0), _make_point(720, 1000)]
|
|
46
|
+
result = interpolate_light_schedule(points, 600)
|
|
47
|
+
assert result[VisualID.RoyalBlue] == 500.0
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def test_interpolation_wraps_across_midnight():
|
|
51
|
+
points = [_make_point(480, 0), _make_point(720, 1000)]
|
|
52
|
+
# 20:00 (1200) is 40% of the way from the 720 point to the wrapped 480+1440 point
|
|
53
|
+
result = interpolate_light_schedule(points, 1200)
|
|
54
|
+
assert result[VisualID.RoyalBlue] == 600.0
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def test_empty_schedule_returns_empty_dict():
|
|
58
|
+
assert interpolate_light_schedule([], 600) == {}
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
# --------------------------------------------------------------------------
|
|
62
|
+
# Brightness as a master dimmer (LightPrimitive.flatten()) -- confirmed
|
|
63
|
+
# from real smali (not just the decompiled Java, which carried JADX's own
|
|
64
|
+
# "Code duplicated" warning on this exact method, the same signal that
|
|
65
|
+
# led to finding the is_night_segment() bug): "Brightness" multiplies
|
|
66
|
+
# EVERY other channel, applied to EACH bracketing schedule point
|
|
67
|
+
# SEPARATELY (by that same point's own Brightness value) BEFORE
|
|
68
|
+
# interpolating between the two already-scaled points -- not interpolate
|
|
69
|
+
# raw values first and multiply by a single Brightness value at the end.
|
|
70
|
+
# Found via real hardware testing: MoonlightBlue/White (and every other
|
|
71
|
+
# channel) were being computed without ever being scaled by Brightness,
|
|
72
|
+
# which the real app's dashboard clearly does.
|
|
73
|
+
# --------------------------------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def _make_point_with_brightness(minute, royal_blue, brightness):
|
|
76
|
+
return SchedulePoint(minute, 1, LightPrimitive({
|
|
77
|
+
VisualID.RoyalBlue: royal_blue, VisualID.Brightness: brightness,
|
|
78
|
+
}))
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def test_brightness_scales_other_channels_at_a_constant_level():
|
|
82
|
+
# Brightness=500 (50%) at both points -> RoyalBlue halved throughout,
|
|
83
|
+
# not left at its raw 1000.
|
|
84
|
+
points = [
|
|
85
|
+
_make_point_with_brightness(480, 1000, 500),
|
|
86
|
+
_make_point_with_brightness(720, 1000, 500),
|
|
87
|
+
]
|
|
88
|
+
result = interpolate_light_schedule(points, 600)
|
|
89
|
+
assert result[VisualID.RoyalBlue] == 500.0
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_flatten_before_interpolate_not_interpolate_before_flatten():
|
|
93
|
+
"""The actual point of this fix, made mathematically unambiguous:
|
|
94
|
+
Brightness and RoyalBlue both ramp 0->1000 across the same segment.
|
|
95
|
+
Flattening each endpoint by its OWN Brightness first, then
|
|
96
|
+
interpolating between the two flattened endpoints, gives 500 at the
|
|
97
|
+
midpoint. Interpolating the raw values first and multiplying by a
|
|
98
|
+
single interpolated Brightness at the end gives 250 instead -- these
|
|
99
|
+
are NOT the same, and only the former matches the confirmed real
|
|
100
|
+
smali (flatten() called on both point copies before the
|
|
101
|
+
interpolation loop, not after it)."""
|
|
102
|
+
points = [
|
|
103
|
+
_make_point_with_brightness(480, 0, 0),
|
|
104
|
+
_make_point_with_brightness(720, 1000, 1000),
|
|
105
|
+
]
|
|
106
|
+
result = interpolate_light_schedule(points, 600) # exact midpoint
|
|
107
|
+
assert result[VisualID.RoyalBlue] == 500.0 # NOT 250.0
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def test_brightness_itself_is_not_flattened_to_1000():
|
|
111
|
+
"""Unlike the real flatten() (which resets Brightness to 1000 on the
|
|
112
|
+
primitive it's called on, having "absorbed" it into every other
|
|
113
|
+
channel), this library's returned dict keeps Brightness as the
|
|
114
|
+
actual raw interpolated master-dimmer level -- callers like a
|
|
115
|
+
dedicated Brightness sensor want that real value, not a constant
|
|
116
|
+
100% that would carry no information."""
|
|
117
|
+
points = [
|
|
118
|
+
_make_point_with_brightness(480, 1000, 0),
|
|
119
|
+
_make_point_with_brightness(720, 1000, 1000),
|
|
120
|
+
]
|
|
121
|
+
result = interpolate_light_schedule(points, 600) # midpoint
|
|
122
|
+
assert result[VisualID.Brightness] == 500.0 # raw interpolation, not 1000
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def test_missing_brightness_channel_is_a_no_op():
|
|
126
|
+
"""Matches flatten()'s own visuals().contains(Brightness) guard --
|
|
127
|
+
if a point has no Brightness channel at all, other channels aren't
|
|
128
|
+
scaled by anything (equivalent to a no-op 1000/100% multiplier), not
|
|
129
|
+
zeroed out or otherwise affected. Confirms none of the existing
|
|
130
|
+
tests above this one (all written before this fix, none setting
|
|
131
|
+
Brightness at all) needed to change."""
|
|
132
|
+
points = [_make_point(480, 0), _make_point(720, 1000)]
|
|
133
|
+
result = interpolate_light_schedule(points, 600)
|
|
134
|
+
assert result[VisualID.RoyalBlue] == 500.0
|
|
135
|
+
assert VisualID.Brightness not in result
|
|
@@ -1,58 +0,0 @@
|
|
|
1
|
-
"""
|
|
2
|
-
Light schedule parsing/interpolation tests. The interpolation algorithm and
|
|
3
|
-
9-point schedule shape here mirror what was captured live from two real
|
|
4
|
-
Radion XR15 lights -- see documentation/06-light-schedule.md.
|
|
5
|
-
"""
|
|
6
|
-
from mobius import (
|
|
7
|
-
LightPrimitive, SchedulePoint, interpolate_light_schedule, VisualID,
|
|
8
|
-
)
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
def test_light_primitive_parses_channel_triplets():
|
|
12
|
-
# RoyalBlue(18) = 500
|
|
13
|
-
data = bytes([18]) + (500).to_bytes(2, "little")
|
|
14
|
-
lp = LightPrimitive.parse(data)
|
|
15
|
-
assert lp.get(VisualID.RoyalBlue) == 500
|
|
16
|
-
assert lp.get(VisualID.Blue) == 0 # unset channel defaults to 0
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def test_schedule_point_parses_time_flags_and_primitive():
|
|
20
|
-
raw_channel = bytes([18]) + (500).to_bytes(2, "little")
|
|
21
|
-
raw_point = (600).to_bytes(2, "little") + bytes([1]) + raw_channel
|
|
22
|
-
sp = SchedulePoint.parse(raw_point)
|
|
23
|
-
assert sp.time_minutes == 600
|
|
24
|
-
assert sp.flags == 1
|
|
25
|
-
assert sp.light.get(VisualID.RoyalBlue) == 500
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
def test_inactive_point_returns_none():
|
|
29
|
-
raw_channel = bytes([18]) + (500).to_bytes(2, "little")
|
|
30
|
-
raw_point = (600).to_bytes(2, "little") + bytes([0]) + raw_channel # flags=0, not ACTIVE
|
|
31
|
-
assert SchedulePoint.parse(raw_point) is None
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _make_point(minute, royal_blue):
|
|
35
|
-
return SchedulePoint(minute, 1, LightPrimitive({VisualID.RoyalBlue: royal_blue}))
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
def test_interpolation_at_exact_points():
|
|
39
|
-
points = [_make_point(480, 0), _make_point(720, 1000)]
|
|
40
|
-
assert interpolate_light_schedule(points, 480)[VisualID.RoyalBlue] == 0.0
|
|
41
|
-
assert interpolate_light_schedule(points, 720)[VisualID.RoyalBlue] == 1000.0
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
def test_interpolation_halfway_between_points():
|
|
45
|
-
points = [_make_point(480, 0), _make_point(720, 1000)]
|
|
46
|
-
result = interpolate_light_schedule(points, 600)
|
|
47
|
-
assert result[VisualID.RoyalBlue] == 500.0
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def test_interpolation_wraps_across_midnight():
|
|
51
|
-
points = [_make_point(480, 0), _make_point(720, 1000)]
|
|
52
|
-
# 20:00 (1200) is 40% of the way from the 720 point to the wrapped 480+1440 point
|
|
53
|
-
result = interpolate_light_schedule(points, 1200)
|
|
54
|
-
assert result[VisualID.RoyalBlue] == 600.0
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def test_empty_schedule_returns_empty_dict():
|
|
58
|
-
assert interpolate_light_schedule([], 600) == {}
|
|
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
|
{python_mobius-0.3.0 → python_mobius-0.3.1}/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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|