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.
Files changed (64) hide show
  1. {python_mobius-0.3.0 → python_mobius-0.3.1}/CHANGELOG.md +29 -0
  2. {python_mobius-0.3.0 → python_mobius-0.3.1}/PKG-INFO +1 -1
  3. {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/06-light-schedule.md +43 -4
  4. {python_mobius-0.3.0 → python_mobius-0.3.1}/pyproject.toml +1 -1
  5. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/__init__.py +1 -1
  6. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/schedule.py +50 -1
  7. python_mobius-0.3.1/tests/test_light_schedule.py +135 -0
  8. python_mobius-0.3.0/tests/test_light_schedule.py +0 -58
  9. {python_mobius-0.3.0 → python_mobius-0.3.1}/.forgejo/scripts/extract_changelog_section.py +0 -0
  10. {python_mobius-0.3.0 → python_mobius-0.3.1}/.forgejo/workflows/release.yml +0 -0
  11. {python_mobius-0.3.0 → python_mobius-0.3.1}/.gitignore +0 -0
  12. {python_mobius-0.3.0 → python_mobius-0.3.1}/LICENSE +0 -0
  13. {python_mobius-0.3.0 → python_mobius-0.3.1}/README.md +0 -0
  14. {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/00-overview.md +0 -0
  15. {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/01-ble-transport.md +0 -0
  16. {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/02-framing-and-crc.md +0 -0
  17. {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/03-attributes-and-opcodes.md +0 -0
  18. {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/04-device-identity.md +0 -0
  19. {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/05-scenes.md +0 -0
  20. {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/07-pump-schedule.md +0 -0
  21. {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/08-manufacturer-data.md +0 -0
  22. {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/09-thread-coap-relay.md +0 -0
  23. {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/10-known-gaps-and-open-questions.md +0 -0
  24. {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/11-device-status-attributes.md +0 -0
  25. {python_mobius-0.3.0 → python_mobius-0.3.1}/documentation/12-device-identity-and-address-stability.md +0 -0
  26. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/cli.py +0 -0
  27. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/coap.py +0 -0
  28. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/constants.py +0 -0
  29. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/crc.py +0 -0
  30. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/device.py +0 -0
  31. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/device_status.py +0 -0
  32. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/discovery.py +0 -0
  33. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/frame.py +0 -0
  34. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/manufacturer.py +0 -0
  35. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/mesh_address.py +0 -0
  36. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/modifiers.py +0 -0
  37. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/power.py +0 -0
  38. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/pump_status.py +0 -0
  39. {python_mobius-0.3.0 → python_mobius-0.3.1}/src/mobius/relay.py +0 -0
  40. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_coap.py +0 -0
  41. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_coap_indication_handling.py +0 -0
  42. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_connection_cleanup.py +0 -0
  43. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_device_status.py +0 -0
  44. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_device_summary.py +0 -0
  45. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_device_time.py +0 -0
  46. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_firmware_versions.py +0 -0
  47. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_frame.py +0 -0
  48. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_frame_sniffer.py +0 -0
  49. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_hyperdrive_device.py +0 -0
  50. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_insolation_detection.py +0 -0
  51. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_light_intensity_branching.py +0 -0
  52. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_manufacturer.py +0 -0
  53. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_manufacturer_lookup.py +0 -0
  54. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_mesh_address.py +0 -0
  55. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_mesh_discovery.py +0 -0
  56. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_mesh_discovery_direct_connect.py +0 -0
  57. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_modifiers.py +0 -0
  58. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_power.py +0 -0
  59. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_pump_schedule.py +0 -0
  60. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_pump_status.py +0 -0
  61. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_relay.py +0 -0
  62. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_schedule_intensity_scalar.py +0 -0
  63. {python_mobius-0.3.0 → python_mobius-0.3.1}/tests/test_serial_decoding.py +0 -0
  64. {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.0
3
+ Version: 0.3.1
4
4
  Summary: Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)
5
5
  Project-URL: Homepage, https://code.r3pek.org/r3pek/python-mobius
6
6
  Project-URL: Documentation, https://code.r3pek.org/r3pek/python-mobius/src/branch/main/documentation
@@ -81,12 +81,51 @@ Ported from `PointSchedule.getIntensitiesAtTime()`:
81
81
  2. Find the two points that bracket the target minute-of-day, wrapping
82
82
  around midnight (the point after the last one is treated as being on
83
83
  "the next day," i.e. its time + 1440).
84
- 3. 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).
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "python-mobius"
7
- version = "0.3.0"
7
+ version = "0.3.1"
8
8
  description = "Reverse-engineered Python client for the Mobius BLE protocol (EcoTech Marine VorTech/Radion, AquaIllumination, Neptune Systems, NYOS)"
9
9
  readme = "README.md"
10
10
  license = { text = "GPL-2.0-only" }
@@ -63,7 +63,7 @@ from .discovery import (
63
63
  dedupe_by_serial, find_device_by_serial, discover_mesh_peers_via_direct_connect,
64
64
  )
65
65
 
66
- __version__ = "0.3.0"
66
+ __version__ = "0.3.1"
67
67
 
68
68
  __all__ = [
69
69
  "__version__",
@@ -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
- result[ch] = v1 + (v2 - v1) * frac
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