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
+9
View File
@@ -0,0 +1,9 @@
__pycache__/
*.pyc
*.egg-info/
.pytest_cache/
.coverage
htmlcov/
build/
dist/
.venv/
+25
View File
@@ -0,0 +1,25 @@
MIT License
Copyright (c) 2026 Sebastian Krüger
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Note: this license covers the code in this repository. The bundled Hue scene
dataset (src/openrgb_hue/data/philips-hue-scenes.csv) is third-party data;
see src/openrgb_hue/data/ATTRIBUTION.txt for its provenance and licensing note.
+222
View File
@@ -0,0 +1,222 @@
# openrgb-hue
Turn Philips Hue scene palettes into gradients and animations on your
[OpenRGB](https://openrgb.org/)-controlled lights — motherboards, GPUs, LED
strips, keyboards, and anything else OpenRGB can see.
## Features
- 103 bundled Philips Hue scenes, each turned into a smooth, cyclic gradient
(RGB or HSV interpolation).
- Six **mapping modes** for how the gradient lands on your actual LEDs:
`sequence`, `per-device`, `per-zone`, `matrix` (with directional variants
for keyboards and other grid devices), `mirror`, `shuffle`.
- Five **animation modes**: `static`, `scroll`, `pingpong`, `pulse`, `wave`.
- A polished terminal UI (via [Typer](https://typer.tiangolo.com/) +
[Rich](https://rich.readthedocs.io/)) with true-color gradient/scene
swatch previews.
- `--dry-run` on `apply`/`animate` renders a full preview against a
synthetic LED layout — no OpenRGB connection required.
- Works fully offline; refresh the bundled scene dataset on demand with
`openrgb-hue scenes update`.
## Prerequisites
- Python 3.10+
- [OpenRGB](https://openrgb.org/) installed and running, with its **SDK
Server started** (Settings → SDK Server → Start Server). Default:
`127.0.0.1:6742`.
## Installation
```bash
pip install openrgb-hue
```
From source, for development:
```bash
git clone <this-repo>
cd openrgb-hue
pip install -e ".[dev]"
```
## Quickstart
```bash
# See what OpenRGB can see.
openrgb-hue devices
# Browse the bundled Hue scenes.
openrgb-hue scenes list
openrgb-hue scenes show "Tropical twilight"
# Apply a scene as a static gradient across every connected LED.
openrgb-hue apply "Tropical twilight"
# ...or animate it, scrolling across your lights until you press Ctrl+C.
openrgb-hue animate "Tropical twilight" --mode scroll
```
## Concepts
### From scene to gradient
Each bundled Hue scene is a small discrete color palette (usually 2-6
distinct colors) recorded across up to 10 lights. `openrgb-hue` turns that
into a gradient by:
1. Reading each light's color in its recorded order.
2. Optionally scaling each color by that light's recorded brightness
(`--brightness`/`--no-brightness`, on by default).
3. Collapsing consecutive duplicate colors into one gradient stop
(`--dedupe`/`--no-dedupe`, on by default) so flat repeated segments don't
waste gradient space.
4. Spacing the resulting colors evenly across `[0, 1]` and interpolating
between them — in RGB or HSV space (`--interpolation rgb|hsv`, HSV by
default, since it avoids muddy grey midpoints between distant hues).
The gradient is always **cyclic**: the last stop blends back into the
first. This is what makes `scroll` and `pingpong` animation seamless.
Preview exactly what a scene's gradient looks like with:
```bash
openrgb-hue scenes show "Blossom" --interpolation rgb --no-dedupe
```
### Mapping modes
Mapping decides *where on the gradient* each physical LED sits.
| Mode | Description |
| --- | --- |
| `sequence` (default) | All targeted LEDs, in order, span the gradient once. |
| `per-device` | Every device shows the full gradient independently (e.g. each LED strip gets its own rainbow). |
| `per-zone` | Like `per-device`, but per zone — useful for multi-zone devices (e.g. a case with front/top fans). |
| `matrix` | 2D directional mapping for grid devices (keyboards, LED matrices), using `--direction`. |
| `mirror` | The gradient plays forward then backward across the target set (a spatial ping-pong layout). |
| `shuffle` | The gradient's colors are assigned to LEDs in a randomized (but seed-deterministic) order. |
`matrix` mode supports four `--direction` values:
- `left-right` — gradient flows across columns.
- `top-bottom` — gradient flows across rows.
- `diagonal` — gradient flows from the top-left corner to the bottom-right.
- `radial` — gradient flows outward from the grid's center.
```bash
openrgb-hue apply "Savanna sunset" --mapping per-device
openrgb-hue apply "Savanna sunset" --device-type keyboard --mapping matrix --direction radial
```
### Animation modes
Animation decides *how the mapped gradient changes over time*. Any
animation mode works with any mapping mode: mapping only ever produces a
static per-LED gradient position, and animation only ever shifts that
position and/or brightness over time.
| Mode | Description | Key flags |
| --- | --- | --- |
| `static` | No motion (used internally by `apply`; useful with `animate --duration` to just hold a look). | — |
| `scroll` (default for `animate`) | The gradient continuously translates, wrapping seamlessly. | `--speed` (cycles/sec) |
| `pingpong` | Like `scroll`, but reverses direction at the ends instead of wrapping. | `--speed` |
| `pulse` | The whole gradient's brightness breathes sinusoidally. | `--speed` (Hz), `--min-brightness` |
| `wave` | A brightness sine wave travels spatially across the LEDs. | `--speed`, `--min-brightness`, `--wavelength` |
```bash
openrgb-hue animate "Tropical twilight" --mode wave --speed 0.3 --wavelength 3 --fps 60
openrgb-hue animate "Relax" --mode pulse --min-brightness 0.1 --duration 30
```
## Full command reference
```
openrgb-hue [--host HOST] [--port PORT] [--version] COMMAND [ARGS]...
devices [--leds] List connected OpenRGB devices (and optionally zones/LEDs).
scenes list [--filter TEXT] List bundled Hue scenes with a gradient preview.
scenes show NAME [...] Show a scene's raw light data and derived gradient.
scenes update [--url] [--timeout] Refresh the bundled scene dataset from the source gist.
apply SCENE [OPTIONS] Apply a scene as a static gradient.
animate SCENE [OPTIONS] Apply a scene as an animated gradient.
off [--device] [--device-type] [--zone] Turn off targeted (or all) LEDs.
clear Turn off every LED on every device.
```
Run `openrgb-hue COMMAND --help` for the full, up-to-date flag list of any
command — every flag mentioned in this README (mapping/animation options,
targeting filters, dry-run flags, etc.) is documented there too.
## Targeting devices and zones
`apply`, `animate`, and `off` all accept repeatable filters:
- `--device NAME` — match devices with this exact name (case-insensitive).
- `--device-type TYPE` — match devices of this type, e.g. `gpu`,
`motherboard`, `ledstrip`, `keyboard` (case-insensitive).
- `--zone NAME` — match zones with this exact name (case-insensitive).
Filters within the same category are OR'd together; different categories
are AND'd. No filters means "every LED on every device". Run
`openrgb-hue devices --leds` to see exact device, zone, and LED names.
```bash
openrgb-hue apply "Bright" --device-type gpu --device-type motherboard
openrgb-hue apply "Bright" --zone "Front Fans"
```
## Dry-run / preview without hardware
Both `apply` and `animate` accept `--dry-run`, which runs the full
targets → mapping → gradient (→ animation) pipeline against a small
synthetic LED layout and prints the result as terminal swatches — no
OpenRGB connection needed. Useful for previewing a look before touching
real hardware, or for trying out mapping/animation combinations when
OpenRGB isn't running.
```bash
openrgb-hue apply "Blossom" --dry-run --mapping matrix --direction diagonal
openrgb-hue animate "Blossom" --dry-run --mode wave --dry-run-frames 8
```
`--dry-run-leds N` synthesizes N flat LEDs instead of the default mixed
layout (two linear strips plus a 4x4 matrix zone). `--dry-run-frames N`
(on `animate`) controls how many evenly-spaced sample frames are printed.
## Troubleshooting
**"Could not connect to the OpenRGB SDK server"** — make sure the OpenRGB
app is running and its SDK Server has been started (Settings → SDK Server
→ Start Server). If it's running on a different host/port, pass
`--host`/`--port` (or set the `OPENRGB_HOST`/`OPENRGB_PORT` environment
variables).
## Data attribution
The bundled scene data (`src/openrgb_hue/data/philips-hue-scenes.csv`) is
sourced from a public gist compiled by GitHub user
[labmonkey](https://gist.github.com/labmonkey/a641f6b339ed9a71bdced64b9af91ee8),
capturing the default Philips Hue app scenes and their per-light colors via
the Home Assistant API. No explicit license is stated by the source gist;
it's used here, with attribution, as a factual color dataset. Run
`openrgb-hue scenes update` to refresh from the source at any time. See
`src/openrgb_hue/data/ATTRIBUTION.txt` for the full note.
## License
The code in this repository is licensed under the [MIT License](LICENSE).
The bundled scene dataset's licensing follows the source gist (see above).
## Contributing
Issues and pull requests are welcome. Run the test suite with:
```bash
pip install -e ".[dev]"
pytest
```
+13
View File
@@ -0,0 +1,13 @@
Please generate a detailed plan for implementing a magnificent and sophisticated python CLI to control openrgb lights:
- The app applies gradients of any Hue scene (see gist) to the leds in OpenRGB.
- The app offers various mapping modes.
- The app offers various animation modes.
- The app is well-documented and contains a README.md.
References:
- https://github.com/jath03/openrgb-python
- https://gist.github.com/labmonkey/a641f6b339ed9a71bdced64b9af91ee8
Python venv is setup and activated. Git is freshly initialized.
+32
View File
@@ -0,0 +1,32 @@
[build-system]
requires = ["hatchling"]
build-backend = "hatchling.build"
[project]
name = "openrgb-hue"
dynamic = ["version"]
description = "Apply Philips Hue scene gradients to OpenRGB-controlled lights, with multiple mapping and animation modes."
readme = "README.md"
license = { text = "MIT" }
requires-python = ">=3.10"
authors = [{ name = "Sebastian Krüger" }]
dependencies = [
"openrgb-python",
"typer>=0.9",
"rich>=13",
]
[project.optional-dependencies]
dev = ["pytest>=7", "pytest-cov"]
[project.scripts]
openrgb-hue = "openrgb_hue.cli:app"
[tool.hatch.version]
path = "src/openrgb_hue/__init__.py"
[tool.hatch.build.targets.wheel]
packages = ["src/openrgb_hue"]
[tool.pytest.ini_options]
testpaths = ["tests"]
+6
View File
@@ -0,0 +1,6 @@
from openrgb_hue.color import Color
from openrgb_hue.gradient import Gradient, Stop
__version__ = "0.1.0"
__all__ = ["Color", "Gradient", "Stop", "__version__"]
+4
View File
@@ -0,0 +1,4 @@
from openrgb_hue.cli import app
if __name__ == "__main__":
app()
+164
View File
@@ -0,0 +1,164 @@
"""Animation modes: how a mapped gradient changes over time.
Mapping mode decides each LED's fixed position in the gradient; animation
mode decides how that position (and/or brightness) shifts over time. Every
animation mode works with every mapping mode because animation never
inspects ``LedRef`` internals -- it only ever produces/consumes ``t`` and
brightness via :class:`~openrgb_hue.mapping.FrameParams`.
"""
from __future__ import annotations
import math
import time
from typing import TYPE_CHECKING, Callable, ClassVar, Protocol
from openrgb_hue import client as client_mod
from openrgb_hue.client import LedRef
from openrgb_hue.gradient import Gradient
from openrgb_hue.mapping import FrameParams, render_frame
if TYPE_CHECKING:
from openrgb import OpenRGBClient
ANIMATION_NAMES = ("static", "scroll", "pingpong", "pulse", "wave")
class UnknownAnimationModeError(ValueError):
pass
class AnimationMode(Protocol):
name: ClassVar[str]
def frame_params(self, elapsed: float) -> FrameParams: ...
class StaticAnimation:
"""No motion; used internally by the one-shot `apply` command."""
name = "static"
def frame_params(self, elapsed: float) -> FrameParams:
return FrameParams()
class ScrollAnimation:
"""Gradient translates continuously, wrapping (the gradient is cyclic)."""
name = "scroll"
def __init__(self, speed: float = 0.1):
self.speed = speed # gradient cycles per second
def frame_params(self, elapsed: float) -> FrameParams:
return FrameParams(t_offset=(elapsed * self.speed) % 1.0)
class PingpongAnimation:
"""Gradient translates then reverses at the boundary instead of wrapping."""
name = "pingpong"
def __init__(self, speed: float = 0.1):
self.speed = speed
def frame_params(self, elapsed: float) -> FrameParams:
raw = (elapsed * self.speed) % 2.0
t_offset = raw if raw <= 1.0 else 2.0 - raw
return FrameParams(t_offset=t_offset)
class PulseAnimation:
"""Global sinusoidal brightness ("breathe"); gradient position frozen."""
name = "pulse"
def __init__(self, speed: float = 0.25, min_brightness: float = 0.05):
self.speed = speed # Hz
self.min_brightness = min_brightness
def frame_params(self, elapsed: float) -> FrameParams:
wave = 0.5 + 0.5 * math.sin(2 * math.pi * self.speed * elapsed)
brightness = self.min_brightness + (1 - self.min_brightness) * wave
return FrameParams(brightness=brightness)
class WaveAnimation:
"""A spatial brightness sine wave (over each LED's base gradient
position) that itself travels over time."""
name = "wave"
def __init__(self, speed: float = 0.25, min_brightness: float = 0.05, wavelength: float = 2.0):
self.speed = speed
self.min_brightness = min_brightness
self.wavelength = wavelength # brightness peaks across the full target set
def frame_params(self, elapsed: float) -> FrameParams:
def brightness_fn(t: float) -> float:
wave = 0.5 + 0.5 * math.sin(2 * math.pi * (t * self.wavelength - elapsed * self.speed))
return self.min_brightness + (1 - self.min_brightness) * wave
return FrameParams(brightness_fn=brightness_fn)
def build_animation(
name: str,
*,
speed: float = 0.1,
min_brightness: float = 0.05,
wavelength: float = 2.0,
) -> AnimationMode:
if name == "static":
return StaticAnimation()
if name == "scroll":
return ScrollAnimation(speed=speed)
if name == "pingpong":
return PingpongAnimation(speed=speed)
if name == "pulse":
return PulseAnimation(speed=speed, min_brightness=min_brightness)
if name == "wave":
return WaveAnimation(speed=speed, min_brightness=min_brightness, wavelength=wavelength)
raise UnknownAnimationModeError(f"Unknown animation mode {name!r}, expected one of {ANIMATION_NAMES}")
def run_animation(
client: "OpenRGBClient",
base: dict[LedRef, float],
gradient: Gradient,
animation: AnimationMode,
*,
fps: float = 30.0,
duration: float | None = None,
restore: bool = True,
on_tick: Callable[[float, FrameParams], None] | None = None,
) -> None:
"""Blocking main animation loop: drift-corrected fps timing, optional
--duration, Ctrl+C handling, and restoring (or leaving) the original
colors on exit. `fast=True` is hardcoded for every write since this is
the performance-sensitive per-frame path."""
snapshot = client_mod.snapshot_colors(client) if restore else None
frame_interval = 1.0 / fps
start = time.monotonic()
frame_index = 0
try:
while True:
elapsed = time.monotonic() - start
if duration is not None and elapsed >= duration:
break
params = animation.frame_params(elapsed)
colors = render_frame(base, gradient, params)
client_mod.apply_colors(client, colors, fast=True)
if on_tick:
on_tick(elapsed, params)
frame_index += 1
next_tick = start + frame_index * frame_interval
sleep_for = next_tick - time.monotonic()
if sleep_for > 0:
time.sleep(sleep_for)
except KeyboardInterrupt:
pass
finally:
if snapshot is not None:
client_mod.restore_colors(client, snapshot, fast=True)
+357
View File
@@ -0,0 +1,357 @@
"""Typer CLI: command parsing, Rich rendering, and wiring the pipeline
(targets -> mapping -> gradient -> animation) together. No color/gradient/
mapping/animation logic lives here -- this module only parses options,
delegates to the other modules, and renders output.
"""
from typing import List, Optional
import typer
from rich import box
from rich.console import Console
from rich.live import Live
from rich.panel import Panel
from rich.table import Table
from rich.text import Text
from rich.tree import Tree
from openrgb_hue import __version__
from openrgb_hue import animation as animation_mod
from openrgb_hue import client as client_mod
from openrgb_hue import mapping as mapping_mod
from openrgb_hue import scenes as scenes_mod
from openrgb_hue import targets as targets_mod
from openrgb_hue.client import LedRef
from openrgb_hue.color import Color
from openrgb_hue.gradient import Gradient
from openrgb_hue.mapping import FrameParams
app = typer.Typer(
name="openrgb-hue",
help="Apply Philips Hue scene gradients to your OpenRGB-controlled lights.",
no_args_is_help=True,
)
scenes_app = typer.Typer(help="Inspect and manage the bundled Hue scene dataset.")
app.add_typer(scenes_app, name="scenes")
console = Console()
err_console = Console(stderr=True)
def _version_callback(value: bool) -> None:
if value:
console.print(f"openrgb-hue {__version__}")
raise typer.Exit()
@app.callback()
def main(
ctx: typer.Context,
host: str = typer.Option("127.0.0.1", envvar="OPENRGB_HOST", help="OpenRGB SDK server host."),
port: int = typer.Option(6742, envvar="OPENRGB_PORT", help="OpenRGB SDK server port."),
version: Optional[bool] = typer.Option(
None, "--version", callback=_version_callback, is_eager=True, help="Show the version and exit."
),
) -> None:
ctx.obj = {"host": host, "port": port}
def _error_exit(message: str) -> None:
err_console.print(Panel(message, title="Error", style="bold red"))
raise typer.Exit(code=1)
def _swatch(colors: List[Color], width: int = 2) -> Text:
text = Text()
for color in colors:
text.append(" " * width, style=f"on #{color.to_hex()}")
return text
def _build_target_filter(device: List[str], device_type: List[str], zone: List[str]) -> targets_mod.TargetFilter:
return targets_mod.TargetFilter(
device_names=tuple(device), device_types=tuple(device_type), zone_names=tuple(zone)
)
def _load_gradient(scene_name: str, dedupe: bool, interpolation: str, brightness: bool) -> Gradient:
try:
scene = scenes_mod.get_scene(scene_name)
except scenes_mod.SceneNotFoundError as exc:
_error_exit(str(exc))
try:
return Gradient.from_scene(scene, dedupe=dedupe, interpolation=interpolation, use_brightness=brightness)
except ValueError as exc:
_error_exit(str(exc))
def _resolve_targets(ctx: typer.Context, device: List[str], device_type: List[str], zone: List[str], dry_run: bool, dry_run_leds: Optional[int]):
if dry_run:
return None, targets_mod.synthetic_targets(dry_run_leds)
try:
client = client_mod.connect(**ctx.obj)
except client_mod.ConnectionError as exc:
_error_exit(str(exc))
all_leds = client_mod.enumerate_leds(client)
filt = _build_target_filter(device, device_type, zone)
try:
targets = targets_mod.select_targets(all_leds, filt)
except targets_mod.NoTargetsError as exc:
_error_exit(str(exc))
return client, targets
def _build_mapping(mapping: str, direction: str, seed: int) -> mapping_mod.MappingMode:
def warn(message: str) -> None:
console.print(f"[yellow]Warning:[/yellow] {message}")
try:
return mapping_mod.build_mapping(mapping, direction=direction, seed=seed, warn=warn)
except (mapping_mod.UnknownMappingModeError, ValueError) as exc:
_error_exit(str(exc))
def _print_led_preview(targets: List[LedRef], colors: dict) -> None:
by_device: dict = {}
for led in targets:
by_device.setdefault(led.device_name, []).append(led)
for device_name, leds in by_device.items():
swatch = _swatch([colors[led] for led in leds])
console.print(Text(f"{device_name:24}") + swatch)
@app.command()
def devices(
ctx: typer.Context,
leds: bool = typer.Option(False, help="Also list every zone and LED."),
) -> None:
"""List all connected OpenRGB devices."""
try:
client = client_mod.connect(**ctx.obj)
except client_mod.ConnectionError as exc:
_error_exit(str(exc))
return
table = Table(box=box.ROUNDED, title="OpenRGB Devices")
table.add_column("Index", justify="right")
table.add_column("Name")
table.add_column("Type")
table.add_column("Zones", justify="right")
table.add_column("LEDs", justify="right")
for i, device in enumerate(client.ee_devices):
table.add_row(str(i), device.name, device.type.name, str(len(device.zones)), str(len(device.leds)))
console.print(table)
if leds:
tree = Tree("Zones & LEDs")
for i, device in enumerate(client.ee_devices):
device_branch = tree.add(f"[bold]{device.name}[/bold] (#{i})")
for zone in device.zones:
zone_branch = device_branch.add(f"{zone.name} ({zone.type.name}, {len(zone.leds)} LEDs)")
for led in zone.leds:
zone_branch.add(led.name)
console.print(tree)
@scenes_app.command("list")
def scenes_list(
name_filter: Optional[str] = typer.Option(None, "--filter", help="Case-insensitive substring filter on scene name."),
) -> None:
"""List all bundled Hue scenes with a gradient preview."""
scenes = scenes_mod.load_scenes()
table = Table(box=box.ROUNDED, title=f"Hue Scenes ({len(scenes)} total)")
table.add_column("Name")
table.add_column("Preview")
shown = 0
for name, scene in scenes.items():
if name_filter and name_filter.lower() not in name.lower():
continue
gradient = Gradient.from_scene(scene)
table.add_row(name, _swatch(gradient.preview_stops(12)))
shown += 1
console.print(table)
if name_filter:
console.print(f"{shown} of {len(scenes)} scenes match {name_filter!r}.")
@scenes_app.command("show")
def scenes_show(
name: str = typer.Argument(..., help="Scene name (see `scenes list`)."),
dedupe: bool = typer.Option(True, help="Collapse consecutive duplicate colors before building the gradient."),
interpolation: str = typer.Option("hsv", help="Gradient interpolation: rgb or hsv."),
brightness: bool = typer.Option(True, help="Apply each light's scene brightness to its gradient stop."),
) -> None:
"""Show a scene's raw light data and its derived gradient preview."""
try:
scene = scenes_mod.get_scene(name)
except scenes_mod.SceneNotFoundError as exc:
_error_exit(str(exc))
return
table = Table(box=box.ROUNDED, title=scene.name)
table.add_column("Light")
table.add_column("Color")
table.add_column("Hex")
table.add_column("Brightness", justify="right")
for light in scene.lights:
table.add_row(light.light_id, _swatch([light.color], width=4), f"#{light.color.to_hex()}", str(light.brightness))
console.print(table)
gradient = Gradient.from_scene(scene, dedupe=dedupe, interpolation=interpolation, use_brightness=brightness)
console.print("Derived gradient:")
console.print(_swatch(gradient.preview_stops(40)))
@scenes_app.command("update")
def scenes_update(
url: str = typer.Option(scenes_mod.GIST_RAW_URL, help="URL to download the scenes CSV from."),
timeout: float = typer.Option(10.0, help="Request timeout in seconds."),
) -> None:
"""Refresh the bundled scene dataset from the source gist."""
before = len(scenes_mod.load_scenes())
try:
path = scenes_mod.refresh_bundled_csv(url=url, timeout=timeout)
except Exception as exc: # noqa: BLE001 - surfaced to the user as a CLI error
_error_exit(f"Failed to refresh scene data: {exc}")
return
after = len(scenes_mod.load_scenes())
console.print(f"Updated {path} ({before} -> {after} scenes).")
@app.command()
def apply(
ctx: typer.Context,
scene: str = typer.Argument(..., help="Hue scene name (see `scenes list`)."),
device: List[str] = typer.Option([], "--device", help="Target only devices with this name (repeatable)."),
device_type: List[str] = typer.Option(
[], "--device-type", help="Target only devices of this type, e.g. gpu, motherboard, ledstrip (repeatable)."
),
zone: List[str] = typer.Option([], "--zone", help="Target only zones with this name (repeatable)."),
mapping: str = typer.Option("sequence", help=f"Mapping mode: {', '.join(mapping_mod.MAPPING_NAMES)}."),
direction: str = typer.Option("left-right", help=f"Matrix direction: {', '.join(mapping_mod.DIRECTIONS)}."),
seed: int = typer.Option(0, help="Shuffle mapping seed."),
dedupe: bool = typer.Option(True, help="Collapse consecutive duplicate colors before building the gradient."),
interpolation: str = typer.Option("hsv", help="Gradient interpolation: rgb or hsv."),
brightness: bool = typer.Option(True, help="Apply each light's scene brightness to its gradient stop."),
dim: float = typer.Option(1.0, help="Global brightness multiplier (0-1), independent of scene brightness."),
fast: bool = typer.Option(False, help="Use OpenRGB's fast (no-readback) update mode."),
dry_run: bool = typer.Option(False, help="Compute and preview without connecting to OpenRGB."),
dry_run_leds: Optional[int] = typer.Option(None, help="Number of synthetic flat LEDs for --dry-run."),
) -> None:
"""Apply a Hue scene as a static gradient."""
gradient = _load_gradient(scene, dedupe, interpolation, brightness)
client, targets = _resolve_targets(ctx, device, device_type, zone, dry_run, dry_run_leds)
mapping_obj = _build_mapping(mapping, direction, seed)
base = mapping_mod.base_positions(mapping_obj, targets)
colors = mapping_mod.render_frame(base, gradient, FrameParams(brightness=dim))
if dry_run:
console.print(f"[bold]{len(targets)}[/bold] synthetic LEDs, mapping=[cyan]{mapping}[/cyan]")
_print_led_preview(targets, colors)
return
client_mod.apply_colors(client, colors, fast=fast)
console.print(f"Applied [bold]{scene}[/bold] to {len(targets)} LEDs (mapping=[cyan]{mapping}[/cyan]).")
@app.command()
def animate(
ctx: typer.Context,
scene: str = typer.Argument(..., help="Hue scene name (see `scenes list`)."),
device: List[str] = typer.Option([], "--device", help="Target only devices with this name (repeatable)."),
device_type: List[str] = typer.Option(
[], "--device-type", help="Target only devices of this type, e.g. gpu, motherboard, ledstrip (repeatable)."
),
zone: List[str] = typer.Option([], "--zone", help="Target only zones with this name (repeatable)."),
mapping: str = typer.Option("sequence", help=f"Mapping mode: {', '.join(mapping_mod.MAPPING_NAMES)}."),
direction: str = typer.Option("left-right", help=f"Matrix direction: {', '.join(mapping_mod.DIRECTIONS)}."),
seed: int = typer.Option(0, help="Shuffle mapping seed."),
dedupe: bool = typer.Option(True, help="Collapse consecutive duplicate colors before building the gradient."),
interpolation: str = typer.Option("hsv", help="Gradient interpolation: rgb or hsv."),
brightness: bool = typer.Option(True, help="Apply each light's scene brightness to its gradient stop."),
dim: float = typer.Option(1.0, help="Global brightness multiplier (0-1), independent of scene brightness."),
mode: str = typer.Option("scroll", help=f"Animation mode: {', '.join(animation_mod.ANIMATION_NAMES)}."),
speed: float = typer.Option(0.1, help="Cycles/sec (scroll, pingpong) or Hz (pulse, wave)."),
fps: float = typer.Option(30.0, help="Target frames per second."),
duration: Optional[float] = typer.Option(None, help="Seconds to run; omit to run until Ctrl+C."),
min_brightness: float = typer.Option(0.05, help="Brightness floor for pulse/wave (0-1)."),
wavelength: float = typer.Option(2.0, help="Brightness peaks across the target set, for wave."),
restore: bool = typer.Option(True, help="Restore original colors when the animation stops."),
dry_run: bool = typer.Option(False, help="Preview sample frames without connecting to OpenRGB."),
dry_run_leds: Optional[int] = typer.Option(None, help="Number of synthetic flat LEDs for --dry-run."),
dry_run_frames: int = typer.Option(5, help="Number of sample frames to preview with --dry-run."),
) -> None:
"""Apply a Hue scene as an animated gradient."""
gradient = _load_gradient(scene, dedupe, interpolation, brightness)
try:
animation_obj = animation_mod.build_animation(
mode, speed=speed, min_brightness=min_brightness, wavelength=wavelength
)
except animation_mod.UnknownAnimationModeError as exc:
_error_exit(str(exc))
return
client, targets = _resolve_targets(ctx, device, device_type, zone, dry_run, dry_run_leds)
mapping_obj = _build_mapping(mapping, direction, seed)
base = mapping_mod.base_positions(mapping_obj, targets)
if dry_run:
period = 1.0 / speed if speed else 10.0
console.print(
f"[bold]{len(targets)}[/bold] synthetic LEDs, mapping=[cyan]{mapping}[/cyan], mode=[cyan]{mode}[/cyan]"
)
frames = max(1, dry_run_frames)
for i in range(frames):
elapsed = period * i / frames
params = animation_obj.frame_params(elapsed)
colors = mapping_mod.render_frame(base, gradient, params)
console.print(f"t={elapsed:.2f}s")
_print_led_preview(targets, colors)
return
console.print(
f"Animating [bold]{scene}[/bold] on {len(targets)} LEDs "
f"(mapping=[cyan]{mapping}[/cyan], mode=[cyan]{mode}[/cyan]). Press Ctrl+C to stop."
)
with Live(console=console, refresh_per_second=8) as live:
def on_tick(elapsed: float, params: FrameParams) -> None:
live.update(Text(f" t={elapsed:6.1f}s fps={fps:g} mode={mode} mapping={mapping}"))
animation_mod.run_animation(
client, base, gradient, animation_obj, fps=fps, duration=duration, restore=restore, on_tick=on_tick
)
console.print()
console.print("Stopped." + (" Original colors restored." if restore else ""))
@app.command()
def off(
ctx: typer.Context,
device: List[str] = typer.Option([], "--device", help="Target only devices with this name (repeatable)."),
device_type: List[str] = typer.Option([], "--device-type", help="Target only devices of this type (repeatable)."),
zone: List[str] = typer.Option([], "--zone", help="Target only zones with this name (repeatable)."),
) -> None:
"""Turn off targeted LEDs (or all LEDs, if no filters are given)."""
try:
client = client_mod.connect(**ctx.obj)
except client_mod.ConnectionError as exc:
_error_exit(str(exc))
return
all_leds = client_mod.enumerate_leds(client)
filt = _build_target_filter(device, device_type, zone)
try:
targets = targets_mod.select_targets(all_leds, filt)
except targets_mod.NoTargetsError as exc:
_error_exit(str(exc))
return
colors = {led: Color(0, 0, 0) for led in targets}
client_mod.apply_colors(client, colors, fast=False)
console.print(f"Turned off {len(targets)} LEDs.")
@app.command()
def clear(ctx: typer.Context) -> None:
"""Turn off every LED on every device."""
off(ctx, device=[], device_type=[], zone=[])
+113
View File
@@ -0,0 +1,113 @@
"""OpenRGB SDK connection, LED enumeration, and color writes.
This is the only module that imports ``openrgb`` at top level; everything
downstream (``targets.py``, ``mapping.py``, ``animation.py``) operates on
the plain, hashable :class:`LedRef` instead of live ``openrgb`` objects, so
it stays testable without a connection.
"""
from __future__ import annotations
from dataclasses import dataclass
from openrgb import OpenRGBClient
from openrgb.orgb import Device
from openrgb.utils import ZoneType
from openrgb_hue.color import Color
class ConnectionError(RuntimeError):
pass
def connect(host: str = "127.0.0.1", port: int = 6742) -> OpenRGBClient:
try:
return OpenRGBClient(address=host, port=port, name="openrgb-hue")
except OSError as exc:
raise ConnectionError(
f"Could not connect to the OpenRGB SDK server at {host}:{port}. "
"Is the OpenRGB app running with the SDK Server started "
"(Settings -> SDK Server -> Start Server)?"
) from exc
@dataclass(frozen=True)
class LedRef:
device_index: int
device_name: str
device_type: str
zone_index: int
zone_name: str
led_index_in_zone: int
led_index_in_device: int
matrix_row: int | None = None
matrix_col: int | None = None
def _invert_matrix_map(matrix_map: list[list[int | None]]) -> dict[int, tuple[int, int]]:
"""Maps zone-local LED index -> (row, col) from a zone's matrix_map grid."""
row_col_by_led_index: dict[int, tuple[int, int]] = {}
for row_idx, row in enumerate(matrix_map):
for col_idx, led_index in enumerate(row):
if led_index is not None:
row_col_by_led_index[led_index] = (row_idx, col_idx)
return row_col_by_led_index
def enumerate_leds(client: OpenRGBClient) -> list[LedRef]:
refs: list[LedRef] = []
for device_index, device in enumerate(client.ee_devices):
for zone_index, zone in enumerate(device.zones):
row_col_by_led_index: dict[int, tuple[int, int]] = {}
if zone.type == ZoneType.MATRIX and zone.matrix_map:
row_col_by_led_index = _invert_matrix_map(zone.matrix_map)
for led_index_in_zone, led in enumerate(zone.leds):
row_col = row_col_by_led_index.get(led_index_in_zone)
refs.append(
LedRef(
device_index=device_index,
device_name=device.name,
device_type=device.type.name,
zone_index=zone_index,
zone_name=zone.name,
led_index_in_zone=led_index_in_zone,
led_index_in_device=led.id,
matrix_row=row_col[0] if row_col else None,
matrix_col=row_col[1] if row_col else None,
)
)
return refs
def apply_colors(client: OpenRGBClient, colors: dict[LedRef, Color], fast: bool = True) -> None:
"""Writes ``colors`` to their devices in bulk via ``Device.set_colors``.
LEDs on a targeted device that are *not* present in ``colors`` (because
filtering narrowed the target set) are left at their current color
rather than being blacked out.
"""
by_device: dict[int, dict[int, Color]] = {}
for led_ref, color in colors.items():
by_device.setdefault(led_ref.device_index, {})[led_ref.led_index_in_device] = color
for device_index, led_colors in by_device.items():
device: Device = client.ee_devices[device_index]
current = device.colors
new_colors = [
led_colors[i].to_openrgb() if i in led_colors else current[i] for i in range(len(current))
]
device.set_colors(new_colors, fast=fast)
def snapshot_colors(client: OpenRGBClient) -> dict[int, list[Color]]:
return {
i: [Color(c.red, c.green, c.blue) for c in device.colors]
for i, device in enumerate(client.ee_devices)
}
def restore_colors(client: OpenRGBClient, snapshot: dict[int, list[Color]], fast: bool = True) -> None:
for device_index, colors in snapshot.items():
device = client.ee_devices[device_index]
device.set_colors([c.to_openrgb() for c in colors], fast=fast)
+78
View File
@@ -0,0 +1,78 @@
"""Color representation and interpolation, independent of any OpenRGB import.
Kept free of any dependency on ``openrgb-python`` so that gradient/mapping/
animation logic can be imported and unit-tested without a live SDK server.
"""
from __future__ import annotations
import colorsys
from dataclasses import dataclass
def _clamp255(value: float) -> int:
return max(0, min(255, round(value)))
def _clamp01(value: float) -> float:
return max(0.0, min(1.0, value))
@dataclass(frozen=True)
class Color:
r: int
g: int
b: int
def __post_init__(self) -> None:
object.__setattr__(self, "r", _clamp255(self.r))
object.__setattr__(self, "g", _clamp255(self.g))
object.__setattr__(self, "b", _clamp255(self.b))
@classmethod
def from_hex(cls, value: str) -> "Color":
value = value.lstrip("#")
if len(value) != 6:
raise ValueError(f"Expected a 6-digit hex color, got {value!r}")
return cls(int(value[0:2], 16), int(value[2:4], 16), int(value[4:6], 16))
def to_hex(self) -> str:
return f"{self.r:02x}{self.g:02x}{self.b:02x}"
def to_openrgb(self) -> "openrgb.utils.RGBColor": # noqa: F821
from openrgb.utils import RGBColor
return RGBColor(self.r, self.g, self.b)
def scale_brightness(self, factor: float) -> "Color":
factor = max(0.0, factor)
return Color(self.r * factor, self.g * factor, self.b * factor)
def rgb_to_hsv(color: Color) -> tuple[float, float, float]:
return colorsys.rgb_to_hsv(color.r / 255, color.g / 255, color.b / 255)
def hsv_to_rgb(h: float, s: float, v: float) -> Color:
r, g, b = colorsys.hsv_to_rgb(h % 1.0, _clamp01(s), _clamp01(v))
return Color(r * 255, g * 255, b * 255)
def rgb_lerp(a: Color, b: Color, t: float) -> Color:
return Color(
a.r + (b.r - a.r) * t,
a.g + (b.g - a.g) * t,
a.b + (b.b - a.b) * t,
)
def hsv_lerp(a: Color, b: Color, t: float) -> Color:
ah, as_, av = rgb_to_hsv(a)
bh, bs, bv = rgb_to_hsv(b)
# Shortest path around the hue circle: e.g. 350deg -> 10deg goes through
# 0deg (dh=+0.055...), not backwards through 180deg.
dh = (bh - ah + 0.5) % 1.0 - 0.5
h = (ah + dh * t) % 1.0
s = as_ + (bs - as_) * t
v = av + (bv - av) * t
return hsv_to_rgb(h, s, v)
+11
View File
@@ -0,0 +1,11 @@
philips-hue-scenes.csv is bundled from a public gist compiled by GitHub user
"labmonkey":
https://gist.github.com/labmonkey/a641f6b339ed9a71bdced64b9af91ee8
It is a dump of the default Philips Hue scenes and their per-light RGB and
brightness values, captured via the Home Assistant API. No explicit license
is stated by the source gist; the data is used here as a factual color
dataset, with attribution, for the purpose of deriving gradient color
palettes. Run `openrgb-hue scenes update` to re-download the latest copy
from the source gist.
File diff suppressed because it is too large Load Diff
+94
View File
@@ -0,0 +1,94 @@
"""Gradient construction and sampling.
A ``Gradient`` is an ordered set of color stops in ``[0, 1]``, sampled at
arbitrary ``t`` via :meth:`Gradient.sample`. Sampling is always cyclic: the
last stop blends back into the first, so ``sample()`` is well-defined for
any real ``t`` with no extrapolation edge cases, and scrolling/ping-pong
animation (which walks ``t`` outside ``[0, 1]``) is seamless with no extra
logic in the animation layer.
"""
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Literal, Sequence
from openrgb_hue.color import Color, hsv_lerp, rgb_lerp
if TYPE_CHECKING:
from openrgb_hue.scenes import Scene
Interpolation = Literal["rgb", "hsv"]
_LERP = {"rgb": rgb_lerp, "hsv": hsv_lerp}
@dataclass(frozen=True)
class Stop:
position: float
color: Color
class Gradient:
def __init__(self, stops: Sequence[Stop], interpolation: Interpolation = "hsv"):
if not stops:
raise ValueError("Gradient needs at least one stop")
if interpolation not in _LERP:
raise ValueError(f"Unknown interpolation {interpolation!r}, expected one of {sorted(_LERP)}")
self.interpolation: Interpolation = interpolation
self._stops: list[Stop] = sorted(stops, key=lambda s: s.position)
@property
def stops(self) -> list[Stop]:
return list(self._stops)
@classmethod
def from_colors(cls, colors: Sequence[Color], interpolation: Interpolation = "hsv") -> "Gradient":
colors = list(colors)
if not colors:
raise ValueError("Gradient needs at least one color")
n = len(colors)
positions = [0.0] if n == 1 else [i / (n - 1) for i in range(n)]
return cls([Stop(p, c) for p, c in zip(positions, colors)], interpolation=interpolation)
@classmethod
def from_scene(
cls,
scene: "Scene",
*,
dedupe: bool = True,
interpolation: Interpolation = "hsv",
use_brightness: bool = True,
) -> "Gradient":
colors: list[Color] = []
for light in scene.lights:
color = light.color.scale_brightness(light.brightness / 255) if use_brightness else light.color
if dedupe and colors and colors[-1] == color:
continue
colors.append(color)
# Avoid a flat/duplicate segment at the cyclic seam when the palette
# happens to start and end on the same color.
if dedupe and len(colors) > 1 and colors[0] == colors[-1]:
colors.pop()
return cls.from_colors(colors, interpolation=interpolation)
def sample(self, t: float) -> Color:
t = t % 1.0
stops = self._stops
if len(stops) == 1:
return stops[0].color
t_eff = t + 1.0 if t < stops[0].position else t
extended = stops + [Stop(stops[0].position + 1.0, stops[0].color)]
lerp = _LERP[self.interpolation]
for a, b in zip(extended, extended[1:]):
if a.position <= t_eff <= b.position:
span = b.position - a.position
local_t = 0.0 if span == 0 else (t_eff - a.position) / span
return lerp(a.color, b.color, local_t)
return stops[-1].color # unreachable, defensive fallback
def preview_stops(self, n: int = 20) -> list[Color]:
if n <= 0:
return []
return [self.sample(i / n) for i in range(n)]
+219
View File
@@ -0,0 +1,219 @@
"""Mapping modes: turn a set of target LEDs into a static gradient position
per LED (``positions()``), and combine that with a gradient plus
animation-supplied time offsets/brightness into actual colors
(``render_frame``).
Mapping modes never deal with time; that is entirely ``animation.py``'s
concern, expressed through :class:`FrameParams`. This is what lets every
mapping mode compose with every animation mode.
"""
from __future__ import annotations
import math
import random
from dataclasses import dataclass
from typing import Callable, ClassVar, Protocol
from openrgb_hue.client import LedRef
from openrgb_hue.color import Color
from openrgb_hue.gradient import Gradient
DIRECTIONS = ("left-right", "top-bottom", "diagonal", "radial")
MAPPING_NAMES = ("sequence", "per-device", "per-zone", "matrix", "mirror", "shuffle")
class UnknownMappingModeError(ValueError):
pass
class MappingMode(Protocol):
name: ClassVar[str]
def positions(self, targets: list[LedRef]) -> dict[LedRef, float]: ...
def _linear_positions(ordered: list[LedRef]) -> dict[LedRef, float]:
n = len(ordered)
if n <= 1:
return {led: 0.0 for led in ordered}
return {led: i / (n - 1) for i, led in enumerate(ordered)}
class SequenceMapping:
"""Flat index order across all targets; the default mapping mode."""
name = "sequence"
def positions(self, targets: list[LedRef]) -> dict[LedRef, float]:
return _linear_positions(targets)
class PerDeviceMapping:
"""The full gradient is repeated independently within each device."""
name = "per-device"
def positions(self, targets: list[LedRef]) -> dict[LedRef, float]:
by_device: dict[int, list[LedRef]] = {}
for led in targets:
by_device.setdefault(led.device_index, []).append(led)
result: dict[LedRef, float] = {}
for group in by_device.values():
result.update(_linear_positions(group))
return result
class PerZoneMapping:
"""The full gradient is repeated independently within each zone."""
name = "per-zone"
def positions(self, targets: list[LedRef]) -> dict[LedRef, float]:
by_zone: dict[tuple[int, int], list[LedRef]] = {}
for led in targets:
by_zone.setdefault((led.device_index, led.zone_index), []).append(led)
result: dict[LedRef, float] = {}
for group in by_zone.values():
result.update(_linear_positions(group))
return result
class MatrixMapping:
"""2D directional mapping over matrix (grid) zones, e.g. keyboards.
Grid bounds for each zone are inferred from the max matrix_row/
matrix_col actually present among the targeted LEDs in that zone.
Targets that aren't part of a matrix zone are held at a fixed
position (0.5), with a one-time warning.
"""
name = "matrix"
def __init__(self, direction: str = "left-right", warn: Callable[[str], None] | None = None):
if direction not in DIRECTIONS:
raise ValueError(f"Unknown matrix direction {direction!r}, expected one of {DIRECTIONS}")
self.direction = direction
self._warn = warn or (lambda _msg: None)
def positions(self, targets: list[LedRef]) -> dict[LedRef, float]:
by_zone: dict[tuple[int, int], list[LedRef]] = {}
for led in targets:
by_zone.setdefault((led.device_index, led.zone_index), []).append(led)
result: dict[LedRef, float] = {}
warned = False
for group in by_zone.values():
matrix_leds = []
non_matrix_leds = []
for led in group:
if led.matrix_row is not None and led.matrix_col is not None:
matrix_leds.append(led)
else:
non_matrix_leds.append(led)
if non_matrix_leds and not warned:
self._warn(
"Some targeted LEDs are not part of a matrix zone; they will be held at a "
"fixed gradient position."
)
warned = True
for led in non_matrix_leds:
result[led] = 0.5
if not matrix_leds:
continue
width = max(led.matrix_col for led in matrix_leds) + 1
height = max(led.matrix_row for led in matrix_leds) + 1
for led in matrix_leds:
result[led] = self._position(led.matrix_row, led.matrix_col, width, height)
return result
def _position(self, row: int, col: int, width: int, height: int) -> float:
if self.direction == "left-right":
return col / (width - 1) if width > 1 else 0.0
if self.direction == "top-bottom":
return row / (height - 1) if height > 1 else 0.0
if self.direction == "diagonal":
denom = (width - 1) + (height - 1)
return (col + row) / denom if denom > 0 else 0.0
# radial: distance from grid center, normalized by the max possible distance.
center_row, center_col = (height - 1) / 2, (width - 1) / 2
dist = math.hypot(row - center_row, col - center_col)
max_dist = math.hypot(center_row, center_col) or 1.0
return min(1.0, dist / max_dist)
class MirrorMapping:
"""Wraps another mode's positions and folds them for a spatial
ping-pong layout: the gradient plays forward then backward across the
target set (as opposed to the temporal `pingpong` *animation* mode).
"""
name = "mirror"
def __init__(self, base: MappingMode | None = None):
self.base = base or SequenceMapping()
def positions(self, targets: list[LedRef]) -> dict[LedRef, float]:
base_positions = self.base.positions(targets)
return {led: 1 - abs(1 - 2 * t) for led, t in base_positions.items()}
class ShuffleMapping:
"""Sequence positions with a seeded, deterministic shuffled assignment."""
name = "shuffle"
def __init__(self, seed: int = 0):
self.seed = seed
def positions(self, targets: list[LedRef]) -> dict[LedRef, float]:
n = len(targets)
positions = [0.0] if n <= 1 else [i / (n - 1) for i in range(n)]
shuffled = list(targets)
random.Random(self.seed).shuffle(shuffled)
return dict(zip(shuffled, positions))
def build_mapping(
name: str,
*,
direction: str = "left-right",
seed: int = 0,
warn: Callable[[str], None] | None = None,
) -> MappingMode:
if name == "sequence":
return SequenceMapping()
if name == "per-device":
return PerDeviceMapping()
if name == "per-zone":
return PerZoneMapping()
if name == "matrix":
return MatrixMapping(direction=direction, warn=warn)
if name == "mirror":
return MirrorMapping()
if name == "shuffle":
return ShuffleMapping(seed=seed)
raise UnknownMappingModeError(f"Unknown mapping mode {name!r}, expected one of {MAPPING_NAMES}")
@dataclass(frozen=True)
class FrameParams:
t_offset: float = 0.0
brightness: float = 1.0
brightness_fn: Callable[[float], float] | None = None
def base_positions(mapping: MappingMode, targets: list[LedRef]) -> dict[LedRef, float]:
"""Computed once per run (targets/mapping don't change frame-to-frame)."""
return mapping.positions(targets)
def render_frame(base: dict[LedRef, float], gradient: Gradient, params: FrameParams) -> dict[LedRef, Color]:
"""The per-frame hot path used by both the one-shot `apply` command and
the animation loop."""
out: dict[LedRef, Color] = {}
for led, t in base.items():
color = gradient.sample(t + params.t_offset)
brightness = params.brightness_fn(t) if params.brightness_fn else params.brightness
out[led] = color.scale_brightness(brightness)
return out
+103
View File
@@ -0,0 +1,103 @@
"""Loading and refreshing the bundled Philips Hue scene dataset.
The dataset is a static CSV (``Scene,Light,Red,Green,Blue,Brightness``)
bundled as package data so the app works fully offline; see
``data/ATTRIBUTION.txt`` for provenance. ``refresh_bundled_csv`` can
re-download the latest copy from the source gist on demand.
"""
from __future__ import annotations
import contextlib
import csv
import io
import os
import tempfile
import urllib.request
from dataclasses import dataclass, field
from difflib import get_close_matches
from pathlib import Path
from openrgb_hue.color import Color
GIST_RAW_URL = (
"https://gist.githubusercontent.com/labmonkey/a641f6b339ed9a71bdced64b9af91ee8"
"/raw/a2069c4bf488779c727223f26ebfdfff04af5608/philips-hue-scenes.csv"
)
BUNDLED_CSV_PATH = Path(__file__).parent / "data" / "philips-hue-scenes.csv"
class SceneNotFoundError(ValueError):
pass
@dataclass(frozen=True)
class SceneLight:
light_id: str
color: Color
brightness: int
@dataclass
class Scene:
name: str
lights: list[SceneLight] = field(default_factory=list)
def load_scenes(path: Path | None = None) -> dict[str, Scene]:
"""Parses the scene CSV, grouping rows by scene name in first-seen order."""
path = path or BUNDLED_CSV_PATH
scenes: dict[str, Scene] = {}
with open(path, newline="", encoding="utf-8") as f:
reader = csv.DictReader(f)
for row in reader:
name = row["Scene"]
light = SceneLight(
light_id=row["Light"],
color=Color(int(row["Red"]), int(row["Green"]), int(row["Blue"])),
brightness=int(row["Brightness"]),
)
scenes.setdefault(name, Scene(name=name)).lights.append(light)
return scenes
def list_scene_names(path: Path | None = None) -> list[str]:
return list(load_scenes(path).keys())
def get_scene(name: str, path: Path | None = None) -> Scene:
"""Case-insensitive exact lookup, with a "did you mean" suggestion on miss."""
scenes = load_scenes(path)
for scene_name, scene in scenes.items():
if scene_name.lower() == name.lower():
return scene
suggestions = get_close_matches(name, scenes.keys(), n=3)
hint = f" Did you mean: {', '.join(suggestions)}?" if suggestions else ""
raise SceneNotFoundError(
f"No scene named {name!r}.{hint} Run `openrgb-hue scenes list` to see all scenes."
)
def refresh_bundled_csv(dest: Path | None = None, url: str = GIST_RAW_URL, timeout: float = 10.0) -> Path:
"""Downloads the scene CSV from ``url``, validates it, and atomically
overwrites ``dest`` (defaults to the bundled copy)."""
dest = dest or BUNDLED_CSV_PATH
with urllib.request.urlopen(url, timeout=timeout) as response: # noqa: S310 (fixed https gist URL)
text = response.read().decode("utf-8")
reader = csv.DictReader(io.StringIO(text))
rows = list(reader)
required_columns = {"Scene", "Light", "Red", "Green", "Blue", "Brightness"}
if not rows or not required_columns.issubset(reader.fieldnames or []):
raise ValueError("Downloaded data does not look like a valid Hue scenes CSV")
fd, tmp_path = tempfile.mkstemp(dir=dest.parent, prefix=".philips-hue-scenes-", suffix=".csv.tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8", newline="") as f:
f.write(text)
os.replace(tmp_path, dest)
except Exception:
with contextlib.suppress(FileNotFoundError):
os.remove(tmp_path)
raise
return dest
+96
View File
@@ -0,0 +1,96 @@
"""Turning CLI filter flags into a concrete, ordered list of LedRefs."""
from __future__ import annotations
from dataclasses import dataclass, field
from openrgb_hue.client import LedRef
class NoTargetsError(ValueError):
pass
@dataclass(frozen=True)
class TargetFilter:
device_names: tuple[str, ...] = field(default_factory=tuple)
device_types: tuple[str, ...] = field(default_factory=tuple)
zone_names: tuple[str, ...] = field(default_factory=tuple)
def _matches(led: LedRef, filt: TargetFilter) -> bool:
if filt.device_names and led.device_name.lower() not in {n.lower() for n in filt.device_names}:
return False
if filt.device_types and led.device_type.lower() not in {t.lower() for t in filt.device_types}:
return False
if filt.zone_names and led.zone_name.lower() not in {n.lower() for n in filt.zone_names}:
return False
return True
def select_targets(all_leds: list[LedRef], filt: TargetFilter) -> list[LedRef]:
targets = [led for led in all_leds if _matches(led, filt)]
if not targets:
raise NoTargetsError(
"No LEDs matched the given filters. Run `openrgb-hue devices --leds` to see available "
"device/zone names."
)
return targets
def synthetic_targets(n: int | None = None) -> list[LedRef]:
"""Fabricates a small fake device/zone/LED layout for --dry-run, so the
full targets -> mapping -> gradient pipeline can be exercised (and
tested) with no live OpenRGB connection.
"""
if n is not None:
return [
LedRef(
device_index=0,
device_name="Dry-Run Device",
device_type="LEDSTRIP",
zone_index=0,
zone_name="Dry-Run Zone",
led_index_in_zone=i,
led_index_in_device=i,
)
for i in range(n)
]
leds: list[LedRef] = []
# Two linear strips of different lengths...
for device_index, (device_name, count) in enumerate(
[("Dry-Run LED Strip", 12), ("Dry-Run Motherboard", 8)]
):
for i in range(count):
leds.append(
LedRef(
device_index=device_index,
device_name=device_name,
device_type="LEDSTRIP" if device_index == 0 else "MOTHERBOARD",
zone_index=0,
zone_name="Zone 1",
led_index_in_zone=i,
led_index_in_device=i,
)
)
# ...and a 4x4 matrix zone (e.g. a keyboard) for matrix mapping preview.
device_index = 2
width = height = 4
for row in range(height):
for col in range(width):
led_index = row * width + col
leds.append(
LedRef(
device_index=device_index,
device_name="Dry-Run Keyboard",
device_type="KEYBOARD",
zone_index=0,
zone_name="Matrix",
led_index_in_zone=led_index,
led_index_in_device=led_index,
matrix_row=row,
matrix_col=col,
)
)
return leds
+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()