Add openrgb-hue: Hue scene gradients for OpenRGB lights

Applies gradients derived from 103 bundled Philips Hue scenes to
OpenRGB-controlled LEDs, with six mapping modes (sequence, per-device,
per-zone, matrix, mirror, shuffle) and five animation modes (static,
scroll, pingpong, pulse, wave). Typer/Rich CLI with dry-run preview,
device/zone targeting, and a scenes-update command to refresh the
bundled dataset from its source gist.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-15 12:40:13 +02:00
co-authored by Claude Sonnet 5
commit 71921b5600
24 changed files with 3131 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
from __future__ import annotations
import pytest
from openrgb_hue.client import LedRef
from openrgb_hue.color import Color
from openrgb_hue.scenes import Scene, SceneLight
from openrgb_hue.targets import synthetic_targets
@pytest.fixture
def sample_scene() -> Scene:
"""A small hand-built scene with an intentional consecutive duplicate
(light 2 and 3 share a color) so dedupe behavior is exercised."""
return Scene(
name="Test Scene",
lights=[
SceneLight("light.hue_1", Color(255, 0, 0), 255),
SceneLight("light.hue_2", Color(0, 255, 0), 255),
SceneLight("light.hue_3", Color(0, 255, 0), 255),
SceneLight("light.hue_4", Color(0, 0, 255), 128),
],
)
@pytest.fixture
def synthetic_leds() -> list[LedRef]:
return synthetic_targets(12)
@pytest.fixture
def matrix_leds() -> list[LedRef]:
return [led for led in synthetic_targets() if led.matrix_row is not None]
+121
View File
@@ -0,0 +1,121 @@
from dataclasses import dataclass
import pytest
from openrgb_hue.animation import (
ANIMATION_NAMES,
PingpongAnimation,
PulseAnimation,
ScrollAnimation,
StaticAnimation,
UnknownAnimationModeError,
WaveAnimation,
build_animation,
run_animation,
)
from openrgb_hue.gradient import Gradient
from openrgb_hue.color import Color
from openrgb_hue.mapping import SequenceMapping, base_positions
from openrgb_hue.targets import synthetic_targets
def test_static_animation_never_moves():
anim = StaticAnimation()
params = anim.frame_params(elapsed=100.0)
assert params.t_offset == 0.0
assert params.brightness == 1.0
assert params.brightness_fn is None
def test_scroll_animation_offset_formula():
anim = ScrollAnimation(speed=0.5) # half a cycle per second
assert anim.frame_params(0.0).t_offset == pytest.approx(0.0)
assert anim.frame_params(1.0).t_offset == pytest.approx(0.5)
assert anim.frame_params(2.0).t_offset == pytest.approx(0.0) # wraps
def test_pingpong_animation_reverses_at_boundary():
anim = PingpongAnimation(speed=1.0)
assert anim.frame_params(0.0).t_offset == pytest.approx(0.0)
assert anim.frame_params(1.0).t_offset == pytest.approx(1.0) # peak
assert anim.frame_params(1.5).t_offset == pytest.approx(0.5) # reversing
assert anim.frame_params(2.0).t_offset == pytest.approx(0.0) # trough
def test_pulse_animation_brightness_bounds():
anim = PulseAnimation(speed=1.0, min_brightness=0.1)
values = [anim.frame_params(t / 8).brightness for t in range(9)]
assert max(values) == pytest.approx(1.0, abs=1e-6)
assert min(values) == pytest.approx(0.1, abs=1e-6)
def test_wave_animation_has_per_led_brightness_fn():
anim = WaveAnimation(speed=0.0, min_brightness=0.0, wavelength=1.0)
params = anim.frame_params(elapsed=0.0)
assert params.brightness_fn is not None
# At elapsed=0, brightness_fn(t) is a sine over t itself.
assert params.brightness_fn(0.0) == pytest.approx(0.5, abs=1e-6)
assert params.brightness_fn(0.25) == pytest.approx(1.0, abs=1e-6)
def test_build_animation_unknown_name_raises():
with pytest.raises(UnknownAnimationModeError):
build_animation("not-a-real-mode")
def test_build_animation_covers_all_names():
for name in ANIMATION_NAMES:
assert build_animation(name).name == name
@dataclass
class FakeColor:
red: int
green: int
blue: int
class FakeDevice:
def __init__(self, n_leds: int):
self.colors = [FakeColor(0, 0, 0) for _ in range(n_leds)]
self.calls: list[tuple[list, bool]] = []
def set_colors(self, colors, fast=False):
self.calls.append((list(colors), fast))
self.colors = colors
class FakeClient:
def __init__(self, n_leds: int = 4):
self.ee_devices = [FakeDevice(n_leds)]
def test_run_animation_terminates_and_restores_on_duration():
leds = synthetic_targets(4)
client = FakeClient(n_leds=4)
gradient = Gradient.from_colors([Color(255, 0, 0), Color(0, 0, 255)], interpolation="rgb")
base = base_positions(SequenceMapping(), leds)
anim = ScrollAnimation(speed=1.0)
run_animation(client, base, gradient, anim, fps=50, duration=0.1, restore=True)
device = client.ee_devices[0]
assert len(device.calls) > 0
# Final write should be the restore call, putting LEDs back to black.
final_colors, final_fast = device.calls[-1]
assert final_fast is True
assert all((c.red, c.green, c.blue) == (0, 0, 0) for c in final_colors)
def test_run_animation_no_restore_leaves_last_frame():
leds = synthetic_targets(4)
client = FakeClient(n_leds=4)
gradient = Gradient.from_colors([Color(255, 0, 0)], interpolation="rgb")
base = base_positions(SequenceMapping(), leds)
anim = StaticAnimation()
run_animation(client, base, gradient, anim, fps=50, duration=0.05, restore=False)
device = client.ee_devices[0]
final_colors, _ = device.calls[-1]
assert all((c.red, c.green, c.blue) == (255, 0, 0) for c in final_colors)
+64
View File
@@ -0,0 +1,64 @@
import pytest
from typer.testing import CliRunner
from openrgb_hue.cli import app
runner = CliRunner()
def test_scenes_list_shows_scene_count():
result = runner.invoke(app, ["scenes", "list"])
assert result.exit_code == 0
assert "103" in result.output
def test_scenes_list_filter():
result = runner.invoke(app, ["scenes", "list", "--filter", "tropical"])
assert result.exit_code == 0
assert "Tropical twilight" in result.output
def test_scenes_show_known_scene():
result = runner.invoke(app, ["scenes", "show", "Tropical twilight"])
assert result.exit_code == 0
assert "Tropical twilight" in result.output
assert "Derived gradient" in result.output
def test_scenes_show_unknown_scene_errors():
result = runner.invoke(app, ["scenes", "show", "Definitely Not A Scene"])
assert result.exit_code == 1
@pytest.mark.parametrize("mapping_mode", ["sequence", "per-device", "per-zone", "mirror", "shuffle"])
def test_apply_dry_run_across_mapping_modes(mapping_mode):
result = runner.invoke(app, ["apply", "Tropical twilight", "--dry-run", "--mapping", mapping_mode])
assert result.exit_code == 0, result.output
assert "synthetic LEDs" in result.output
def test_apply_dry_run_matrix_radial():
result = runner.invoke(
app,
["apply", "Tropical twilight", "--dry-run", "--mapping", "matrix", "--direction", "radial"],
)
assert result.exit_code == 0, result.output
@pytest.mark.parametrize("mode", ["static", "scroll", "pingpong", "pulse", "wave"])
def test_animate_dry_run_across_animation_modes(mode):
result = runner.invoke(
app, ["animate", "Tropical twilight", "--dry-run", "--mode", mode, "--dry-run-frames", "3"]
)
assert result.exit_code == 0, result.output
assert "t=0.00s" in result.output
def test_apply_unknown_scene_errors():
result = runner.invoke(app, ["apply", "Not A Real Scene", "--dry-run"])
assert result.exit_code == 1
def test_apply_unknown_mapping_mode_errors():
result = runner.invoke(app, ["apply", "Tropical twilight", "--dry-run", "--mapping", "nonsense"])
assert result.exit_code == 1
+47
View File
@@ -0,0 +1,47 @@
from openrgb_hue.color import Color, hsv_lerp, rgb_lerp, rgb_to_hsv
def test_color_clamps_out_of_range():
assert Color(300, -10, 128) == Color(255, 0, 128)
def test_color_hex_roundtrip():
c = Color(18, 52, 86)
assert c.to_hex() == "123456"
assert Color.from_hex("#123456") == c
def test_rgb_lerp_endpoints_and_midpoint():
a, b = Color(0, 0, 0), Color(100, 200, 50)
assert rgb_lerp(a, b, 0.0) == a
assert rgb_lerp(a, b, 1.0) == b
mid = rgb_lerp(a, b, 0.5)
assert mid == Color(50, 100, 25)
def test_hsv_lerp_endpoints():
a, b = Color(255, 0, 0), Color(0, 0, 255)
assert hsv_lerp(a, b, 0.0) == a
assert hsv_lerp(a, b, 1.0) == b
def test_hsv_lerp_takes_shortest_hue_path():
# Hue 350deg -> 10deg should pass through 0deg/360deg, not through 180deg.
a = Color.from_hex("#ff0022") # hue ~350deg
b = Color.from_hex("#ff2200") # hue ~10deg
mid = hsv_lerp(a, b, 0.5)
mid_h, _, _ = rgb_to_hsv(mid)
# Near 0/360deg, not anywhere near 180deg.
assert mid_h < 0.05 or mid_h > 0.95
def test_scale_brightness():
c = Color(200, 100, 50)
assert c.scale_brightness(0.5) == Color(100, 50, 25)
assert c.scale_brightness(0.0) == Color(0, 0, 0)
def test_to_openrgb_conversion():
c = Color(1, 2, 3)
rgb = c.to_openrgb()
assert (rgb.red, rgb.green, rgb.blue) == (1, 2, 3)
+58
View File
@@ -0,0 +1,58 @@
from openrgb_hue.color import Color
from openrgb_hue.gradient import Gradient, Stop
def test_from_colors_even_spacing_single():
gradient = Gradient.from_colors([Color(10, 20, 30)])
assert [s.position for s in gradient.stops] == [0.0]
def test_from_colors_even_spacing_multiple():
colors = [Color(0, 0, 0), Color(50, 50, 50), Color(255, 255, 255)]
gradient = Gradient.from_colors(colors)
assert [s.position for s in gradient.stops] == [0.0, 0.5, 1.0]
def test_sample_at_exact_stop_positions():
colors = [Color(255, 0, 0), Color(0, 255, 0), Color(0, 0, 255)]
gradient = Gradient.from_colors(colors, interpolation="rgb")
assert gradient.sample(0.0) == colors[0]
assert gradient.sample(0.5) == colors[1]
assert gradient.sample(1.0) == colors[0] # cyclic: wraps back to the first stop
def test_sample_wraps_cyclically():
colors = [Color(255, 0, 0), Color(0, 0, 255)]
gradient = Gradient.from_colors(colors, interpolation="rgb")
# t=0.75 is halfway between the last stop (1.0 -> wraps to 0.0) and back
# to the first stop; verify negative/over-1 t wrap to the same result.
assert gradient.sample(-0.25) == gradient.sample(0.75)
assert gradient.sample(1.25) == gradient.sample(0.25)
def test_single_stop_gradient_is_constant():
gradient = Gradient([Stop(0.3, Color(1, 2, 3))])
assert gradient.sample(0.0) == Color(1, 2, 3)
assert gradient.sample(0.9) == Color(1, 2, 3)
def test_from_scene_dedupe_collapses_consecutive_duplicates(sample_scene):
with_dedupe = Gradient.from_scene(sample_scene, dedupe=True, use_brightness=False)
without_dedupe = Gradient.from_scene(sample_scene, dedupe=False, use_brightness=False)
# sample_scene has 4 lights but lights 2 and 3 share a color.
assert len(with_dedupe.stops) == 3
assert len(without_dedupe.stops) == 4
def test_from_scene_use_brightness_scales_colors(sample_scene):
gradient = Gradient.from_scene(sample_scene, dedupe=False, use_brightness=True)
# Fourth light: Color(0, 0, 255) at brightness 128/255.
last_stop_color = gradient.stops[-1].color
expected = Color(0, 0, 255).scale_brightness(128 / 255)
assert last_stop_color == expected
def test_preview_stops_length():
gradient = Gradient.from_colors([Color(255, 0, 0), Color(0, 255, 0)])
assert len(gradient.preview_stops(10)) == 10
assert gradient.preview_stops(0) == []
+139
View File
@@ -0,0 +1,139 @@
import pytest
from openrgb_hue.color import Color
from openrgb_hue.gradient import Gradient
from openrgb_hue.mapping import (
FrameParams,
MatrixMapping,
MirrorMapping,
PerDeviceMapping,
PerZoneMapping,
SequenceMapping,
ShuffleMapping,
UnknownMappingModeError,
base_positions,
build_mapping,
render_frame,
)
from openrgb_hue.targets import synthetic_targets
def test_sequence_mapping_linear_spacing(synthetic_leds):
positions = SequenceMapping().positions(synthetic_leds)
ordered = [positions[led] for led in synthetic_leds]
assert ordered[0] == 0.0
assert ordered[-1] == 1.0
assert ordered == sorted(ordered)
def test_sequence_mapping_single_target_is_zero():
leds = synthetic_targets(1)
positions = SequenceMapping().positions(leds)
assert positions[leds[0]] == 0.0
def test_per_device_mapping_each_device_spans_full_range():
leds = synthetic_targets() # two linear devices + one matrix device
positions = PerDeviceMapping().positions(leds)
for device_index in {led.device_index for led in leds}:
device_leds = [led for led in leds if led.device_index == device_index]
values = [positions[led] for led in device_leds]
assert min(values) == 0.0
assert max(values) == 1.0
def test_per_zone_mapping_groups_by_zone():
leds = synthetic_targets()
positions = PerZoneMapping().positions(leds)
for key in {(led.device_index, led.zone_index) for led in leds}:
zone_leds = [led for led in leds if (led.device_index, led.zone_index) == key]
values = [positions[led] for led in zone_leds]
assert min(values) == 0.0
assert max(values) == 1.0
@pytest.fixture
def matrix_leds():
return [led for led in synthetic_targets() if led.matrix_row is not None]
def test_matrix_mapping_left_right(matrix_leds):
positions = MatrixMapping(direction="left-right").positions(matrix_leds)
top_left = next(led for led in matrix_leds if led.matrix_row == 0 and led.matrix_col == 0)
top_right = next(led for led in matrix_leds if led.matrix_row == 0 and led.matrix_col == 3)
assert positions[top_left] == 0.0
assert positions[top_right] == 1.0
def test_matrix_mapping_top_bottom(matrix_leds):
positions = MatrixMapping(direction="top-bottom").positions(matrix_leds)
top = next(led for led in matrix_leds if led.matrix_row == 0 and led.matrix_col == 0)
bottom = next(led for led in matrix_leds if led.matrix_row == 3 and led.matrix_col == 0)
assert positions[top] == 0.0
assert positions[bottom] == 1.0
def test_matrix_mapping_diagonal(matrix_leds):
positions = MatrixMapping(direction="diagonal").positions(matrix_leds)
corner_near = next(led for led in matrix_leds if led.matrix_row == 0 and led.matrix_col == 0)
corner_far = next(led for led in matrix_leds if led.matrix_row == 3 and led.matrix_col == 3)
assert positions[corner_near] == 0.0
assert positions[corner_far] == 1.0
def test_matrix_mapping_radial_center_is_minimum(matrix_leds):
positions = MatrixMapping(direction="radial").positions(matrix_leds)
# On a 4x4 grid the center falls between cells; corners should be
# further from center than the near-center cells.
corner = next(led for led in matrix_leds if led.matrix_row == 0 and led.matrix_col == 0)
near_center = next(led for led in matrix_leds if led.matrix_row == 1 and led.matrix_col == 1)
assert positions[near_center] < positions[corner]
def test_matrix_mapping_invalid_direction_raises():
with pytest.raises(ValueError):
MatrixMapping(direction="sideways")
def test_matrix_mapping_warns_on_non_matrix_leds():
leds = synthetic_targets(4) # flat LEDs, no matrix coordinates
warnings = []
MatrixMapping(warn=warnings.append).positions(leds)
assert len(warnings) == 1
assert all(led.matrix_row is None for led in leds)
def test_mirror_mapping_is_symmetric(synthetic_leds):
positions = MirrorMapping().positions(synthetic_leds)
values = [positions[led] for led in synthetic_leds]
n = len(values)
for i in range(n):
assert values[i] == pytest.approx(values[n - 1 - i])
def test_shuffle_mapping_deterministic_and_seed_sensitive(synthetic_leds):
a = ShuffleMapping(seed=1).positions(synthetic_leds)
b = ShuffleMapping(seed=1).positions(synthetic_leds)
c = ShuffleMapping(seed=2).positions(synthetic_leds)
assert a == b
assert a != c
def test_build_mapping_unknown_name_raises():
with pytest.raises(UnknownMappingModeError):
build_mapping("not-a-real-mode")
def test_render_frame_uses_gradient_and_offset(synthetic_leds):
gradient = Gradient.from_colors([Color(255, 0, 0), Color(0, 0, 255)], interpolation="rgb")
base = base_positions(SequenceMapping(), synthetic_leds)
colors = render_frame(base, gradient, FrameParams(t_offset=0.0, brightness=1.0))
assert colors[synthetic_leds[0]] == gradient.sample(0.0)
def test_render_frame_brightness_fn_overrides_scalar_brightness(synthetic_leds):
gradient = Gradient.from_colors([Color(255, 255, 255)], interpolation="rgb")
base = base_positions(SequenceMapping(), synthetic_leds)
params = FrameParams(brightness=1.0, brightness_fn=lambda t: 0.0)
colors = render_frame(base, gradient, params)
assert all(c == Color(0, 0, 0) for c in colors.values())
+92
View File
@@ -0,0 +1,92 @@
from unittest.mock import patch
import pytest
from openrgb_hue.scenes import (
BUNDLED_CSV_PATH,
SceneNotFoundError,
get_scene,
list_scene_names,
load_scenes,
refresh_bundled_csv,
)
SAMPLE_CSV = (
"Scene,Light,Red,Green,Blue,Brightness\n"
"Alpha,light.hue_1,255,0,0,255\n"
"Alpha,light.hue_2,0,255,0,200\n"
"Beta,light.hue_1,0,0,255,100\n"
)
@pytest.fixture
def sample_csv_path(tmp_path):
path = tmp_path / "scenes.csv"
path.write_text(SAMPLE_CSV, encoding="utf-8")
return path
def test_load_scenes_groups_by_name_preserving_order(sample_csv_path):
scenes = load_scenes(sample_csv_path)
assert list(scenes.keys()) == ["Alpha", "Beta"]
assert len(scenes["Alpha"].lights) == 2
assert len(scenes["Beta"].lights) == 1
def test_list_scene_names(sample_csv_path):
assert list_scene_names(sample_csv_path) == ["Alpha", "Beta"]
def test_get_scene_case_insensitive(sample_csv_path):
scene = get_scene("alpha", sample_csv_path)
assert scene.name == "Alpha"
def test_get_scene_missing_raises_with_suggestion(sample_csv_path):
with pytest.raises(SceneNotFoundError, match="Alpha"):
get_scene("Alphaa", sample_csv_path)
def test_bundled_csv_has_103_scenes_of_10_lights_each():
scenes = load_scenes(BUNDLED_CSV_PATH)
assert len(scenes) == 103
assert all(len(scene.lights) == 10 for scene in scenes.values())
def test_refresh_bundled_csv_writes_validated_data(tmp_path):
dest = tmp_path / "scenes.csv"
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return SAMPLE_CSV.encode("utf-8")
with patch("openrgb_hue.scenes.urllib.request.urlopen", return_value=FakeResponse()):
result = refresh_bundled_csv(dest=dest, url="https://example.invalid/scenes.csv")
assert result == dest
assert dest.read_text(encoding="utf-8") == SAMPLE_CSV
def test_refresh_bundled_csv_rejects_invalid_data(tmp_path):
dest = tmp_path / "scenes.csv"
class FakeResponse:
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def read(self):
return b"not,a,valid,csv\n"
with patch("openrgb_hue.scenes.urllib.request.urlopen", return_value=FakeResponse()):
with pytest.raises(ValueError):
refresh_bundled_csv(dest=dest, url="https://example.invalid/scenes.csv")
assert not dest.exists()