From 71921b56003b1d4e91d955fb4dcf2b65818e6827 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Sebastian=20Kr=C3=BCger?= Date: Sat, 15 Aug 2026 12:40:13 +0200 Subject: [PATCH] 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 --- .gitignore | 9 + LICENSE | 25 + README.md | 222 ++++ prompt.txt | 13 + pyproject.toml | 32 + src/openrgb_hue/__init__.py | 6 + src/openrgb_hue/__main__.py | 4 + src/openrgb_hue/animation.py | 164 +++ src/openrgb_hue/cli.py | 357 +++++++ src/openrgb_hue/client.py | 113 ++ src/openrgb_hue/color.py | 78 ++ src/openrgb_hue/data/ATTRIBUTION.txt | 11 + src/openrgb_hue/data/philips-hue-scenes.csv | 1031 +++++++++++++++++++ src/openrgb_hue/gradient.py | 94 ++ src/openrgb_hue/mapping.py | 219 ++++ src/openrgb_hue/scenes.py | 103 ++ src/openrgb_hue/targets.py | 96 ++ tests/conftest.py | 33 + tests/test_animation.py | 121 +++ tests/test_cli.py | 64 ++ tests/test_color.py | 47 + tests/test_gradient.py | 58 ++ tests/test_mapping.py | 139 +++ tests/test_scenes.py | 92 ++ 24 files changed, 3131 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 prompt.txt create mode 100644 pyproject.toml create mode 100644 src/openrgb_hue/__init__.py create mode 100644 src/openrgb_hue/__main__.py create mode 100644 src/openrgb_hue/animation.py create mode 100644 src/openrgb_hue/cli.py create mode 100644 src/openrgb_hue/client.py create mode 100644 src/openrgb_hue/color.py create mode 100644 src/openrgb_hue/data/ATTRIBUTION.txt create mode 100644 src/openrgb_hue/data/philips-hue-scenes.csv create mode 100644 src/openrgb_hue/gradient.py create mode 100644 src/openrgb_hue/mapping.py create mode 100644 src/openrgb_hue/scenes.py create mode 100644 src/openrgb_hue/targets.py create mode 100644 tests/conftest.py create mode 100644 tests/test_animation.py create mode 100644 tests/test_cli.py create mode 100644 tests/test_color.py create mode 100644 tests/test_gradient.py create mode 100644 tests/test_mapping.py create mode 100644 tests/test_scenes.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7bc0a59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +__pycache__/ +*.pyc +*.egg-info/ +.pytest_cache/ +.coverage +htmlcov/ +build/ +dist/ +.venv/ diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..afac062 --- /dev/null +++ b/LICENSE @@ -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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..7787520 --- /dev/null +++ b/README.md @@ -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 +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 +``` diff --git a/prompt.txt b/prompt.txt new file mode 100644 index 0000000..024c8e0 --- /dev/null +++ b/prompt.txt @@ -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. diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..9f0ae32 --- /dev/null +++ b/pyproject.toml @@ -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"] diff --git a/src/openrgb_hue/__init__.py b/src/openrgb_hue/__init__.py new file mode 100644 index 0000000..f525d1d --- /dev/null +++ b/src/openrgb_hue/__init__.py @@ -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__"] diff --git a/src/openrgb_hue/__main__.py b/src/openrgb_hue/__main__.py new file mode 100644 index 0000000..60117f6 --- /dev/null +++ b/src/openrgb_hue/__main__.py @@ -0,0 +1,4 @@ +from openrgb_hue.cli import app + +if __name__ == "__main__": + app() diff --git a/src/openrgb_hue/animation.py b/src/openrgb_hue/animation.py new file mode 100644 index 0000000..bada1c4 --- /dev/null +++ b/src/openrgb_hue/animation.py @@ -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) diff --git a/src/openrgb_hue/cli.py b/src/openrgb_hue/cli.py new file mode 100644 index 0000000..c7b2ad2 --- /dev/null +++ b/src/openrgb_hue/cli.py @@ -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=[]) diff --git a/src/openrgb_hue/client.py b/src/openrgb_hue/client.py new file mode 100644 index 0000000..847e641 --- /dev/null +++ b/src/openrgb_hue/client.py @@ -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) diff --git a/src/openrgb_hue/color.py b/src/openrgb_hue/color.py new file mode 100644 index 0000000..7492249 --- /dev/null +++ b/src/openrgb_hue/color.py @@ -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) diff --git a/src/openrgb_hue/data/ATTRIBUTION.txt b/src/openrgb_hue/data/ATTRIBUTION.txt new file mode 100644 index 0000000..c68f530 --- /dev/null +++ b/src/openrgb_hue/data/ATTRIBUTION.txt @@ -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. diff --git a/src/openrgb_hue/data/philips-hue-scenes.csv b/src/openrgb_hue/data/philips-hue-scenes.csv new file mode 100644 index 0000000..a2069c4 --- /dev/null +++ b/src/openrgb_hue/data/philips-hue-scenes.csv @@ -0,0 +1,1031 @@ +Scene,Light,Red,Green,Blue,Brightness +Tropical twilight,light.hue_1,255,183,130,112 +Tropical twilight,light.hue_2,205,125,255,255 +Tropical twilight,light.hue_3,255,187,237,255 +Tropical twilight,light.hue_4,255,183,130,112 +Tropical twilight,light.hue_5,255,187,237,255 +Tropical twilight,light.hue_6,255,183,130,112 +Tropical twilight,light.hue_7,255,187,237,255 +Tropical twilight,light.hue_8,255,183,130,112 +Tropical twilight,light.hue_9,205,125,255,255 +Tropical twilight,light.hue_10,204,125,255,112 +Blossom,light.hue_1,255,236,164,255 +Blossom,light.hue_2,165,211,255,204 +Blossom,light.hue_3,194,228,255,255 +Blossom,light.hue_4,255,188,69,204 +Blossom,light.hue_5,194,228,255,255 +Blossom,light.hue_6,164,186,255,197 +Blossom,light.hue_7,165,211,255,204 +Blossom,light.hue_8,255,236,165,204 +Blossom,light.hue_9,255,217,127,204 +Blossom,light.hue_10,165,211,255,204 +Bright,light.hue_1,255,167,88,255 +Bright,light.hue_2,255,167,88,255 +Bright,light.hue_3,255,171,97,255 +Bright,light.hue_4,255,167,88,255 +Bright,light.hue_5,255,167,88,255 +Bright,light.hue_6,255,167,88,255 +Bright,light.hue_7,255,167,88,255 +Bright,light.hue_8,255,167,88,255 +Bright,light.hue_9,255,167,88,255 +Bright,light.hue_10,255,167,88,255 +Savanna sunset,light.hue_1,255,205,90,147 +Savanna sunset,light.hue_2,255,239,135,147 +Savanna sunset,light.hue_3,255,94,36,147 +Savanna sunset,light.hue_4,255,167,90,147 +Savanna sunset,light.hue_5,255,94,36,147 +Savanna sunset,light.hue_6,255,94,36,147 +Savanna sunset,light.hue_7,255,205,90,147 +Savanna sunset,light.hue_8,255,130,68,147 +Savanna sunset,light.hue_9,255,239,135,147 +Savanna sunset,light.hue_10,255,205,110,221 +Relax,light.hue_1,255,148,43,143 +Relax,light.hue_2,255,148,43,143 +Relax,light.hue_3,255,148,43,143 +Relax,light.hue_4,255,148,43,143 +Relax,light.hue_5,255,148,43,143 +Relax,light.hue_6,255,149,46,143 +Relax,light.hue_7,255,148,43,143 +Relax,light.hue_8,255,148,43,143 +Relax,light.hue_9,255,148,43,143 +Relax,light.hue_10,255,148,43,143 +Energize,light.hue_1,255,253,248,255 +Energize,light.hue_2,255,253,248,255 +Energize,light.hue_3,255,253,248,255 +Energize,light.hue_4,255,253,248,255 +Energize,light.hue_5,255,253,248,255 +Energize,light.hue_6,255,253,248,255 +Energize,light.hue_7,255,253,248,255 +Energize,light.hue_8,255,253,248,255 +Energize,light.hue_9,255,253,248,255 +Energize,light.hue_10,255,253,248,255 +Dreamy dusk,light.hue_1,255,126,149,255 +Dreamy dusk,light.hue_2,255,126,149,255 +Dreamy dusk,light.hue_3,255,148,184,127 +Dreamy dusk,light.hue_4,255,126,103,127 +Dreamy dusk,light.hue_5,255,126,149,255 +Dreamy dusk,light.hue_6,255,171,89,255 +Dreamy dusk,light.hue_7,255,150,85,127 +Dreamy dusk,light.hue_8,255,150,85,127 +Dreamy dusk,light.hue_9,255,126,103,127 +Dreamy dusk,light.hue_10,255,126,103,127 +Concentrate,light.hue_1,255,213,179,255 +Concentrate,light.hue_2,255,213,179,255 +Concentrate,light.hue_3,255,213,179,255 +Concentrate,light.hue_4,255,213,179,255 +Concentrate,light.hue_5,255,213,179,255 +Concentrate,light.hue_6,255,214,181,255 +Concentrate,light.hue_7,255,214,181,255 +Concentrate,light.hue_8,255,213,179,255 +Concentrate,light.hue_9,255,213,179,255 +Concentrate,light.hue_10,255,213,179,255 +Warm embrace,light.hue_1,255,141,73,102 +Warm embrace,light.hue_2,255,171,81,102 +Warm embrace,light.hue_3,255,117,96,102 +Warm embrace,light.hue_4,255,141,73,102 +Warm embrace,light.hue_5,255,171,81,102 +Warm embrace,light.hue_6,255,171,81,102 +Warm embrace,light.hue_7,255,141,73,102 +Warm embrace,light.hue_8,255,155,77,102 +Warm embrace,light.hue_9,255,154,76,255 +Warm embrace,light.hue_10,255,171,81,102 +Golden pond,light.hue_1,255,211,73,165 +Golden pond,light.hue_2,255,211,73,165 +Golden pond,light.hue_3,255,156,35,165 +Golden pond,light.hue_4,255,202,65,255 +Golden pond,light.hue_5,255,211,73,165 +Golden pond,light.hue_6,255,200,48,165 +Golden pond,light.hue_7,255,211,73,165 +Golden pond,light.hue_8,255,163,42,161 +Golden pond,light.hue_9,255,200,48,165 +Golden pond,light.hue_10,255,202,66,165 +Honolulu,light.hue_1,255,135,76,255 +Honolulu,light.hue_2,255,205,95,99 +Honolulu,light.hue_3,255,154,54,255 +Honolulu,light.hue_4,255,132,48,176 +Honolulu,light.hue_5,255,135,76,255 +Honolulu,light.hue_6,255,205,95,99 +Honolulu,light.hue_7,255,64,78,99 +Honolulu,light.hue_8,255,117,95,99 +Honolulu,light.hue_9,255,135,76,255 +Honolulu,light.hue_10,255,135,77,99 +Rest,light.hue_1,255,138,71,228 +Rest,light.hue_2,255,161,40,89 +Rest,light.hue_3,255,161,40,89 +Rest,light.hue_4,255,161,40,89 +Rest,light.hue_5,255,161,40,89 +Rest,light.hue_6,255,161,40,89 +Rest,light.hue_7,255,161,40,89 +Rest,light.hue_8,255,161,40,89 +Rest,light.hue_9,255,161,40,89 +Rest,light.hue_10,255,161,40,89 +Ruby glow,light.hue_1,255,99,140,102 +Ruby glow,light.hue_2,255,125,157,255 +Ruby glow,light.hue_3,255,99,140,102 +Ruby glow,light.hue_4,255,200,211,102 +Ruby glow,light.hue_5,255,200,211,102 +Ruby glow,light.hue_6,255,125,157,255 +Ruby glow,light.hue_7,255,150,172,102 +Ruby glow,light.hue_8,255,99,140,102 +Ruby glow,light.hue_9,255,200,211,102 +Ruby glow,light.hue_10,255,125,158,102 +Rolling hills,light.hue_1,255,160,62,114 +Rolling hills,light.hue_2,255,160,62,114 +Rolling hills,light.hue_3,255,160,62,114 +Rolling hills,light.hue_4,255,190,114,114 +Rolling hills,light.hue_5,255,183,96,114 +Rolling hills,light.hue_6,255,190,114,114 +Rolling hills,light.hue_7,255,183,96,114 +Rolling hills,light.hue_8,255,174,78,114 +Rolling hills,light.hue_9,255,183,96,114 +Rolling hills,light.hue_10,255,183,96,114 +Precious,light.hue_1,245,175,255,204 +Precious,light.hue_2,255,218,184,204 +Precious,light.hue_3,255,211,139,204 +Precious,light.hue_4,245,175,255,204 +Precious,light.hue_5,255,211,139,204 +Precious,light.hue_6,255,211,139,204 +Precious,light.hue_7,242,140,255,255 +Precious,light.hue_8,242,141,255,204 +Precious,light.hue_9,254,255,154,204 +Precious,light.hue_10,255,211,139,204 +Cool bright,light.hue_1,255,206,166,255 +Cool bright,light.hue_2,255,206,166,255 +Cool bright,light.hue_3,255,206,166,255 +Cool bright,light.hue_4,255,206,166,255 +Cool bright,light.hue_5,255,206,166,255 +Cool bright,light.hue_6,255,206,166,255 +Cool bright,light.hue_7,255,206,166,255 +Cool bright,light.hue_8,255,206,166,255 +Cool bright,light.hue_9,255,206,166,255 +Cool bright,light.hue_10,255,206,166,255 +Mountain breeze,light.hue_1,174,178,255,152 +Mountain breeze,light.hue_2,174,178,255,152 +Mountain breeze,light.hue_3,143,161,255,152 +Mountain breeze,light.hue_4,255,221,142,152 +Mountain breeze,light.hue_5,255,215,200,152 +Mountain breeze,light.hue_6,174,178,255,152 +Mountain breeze,light.hue_7,143,161,255,152 +Mountain breeze,light.hue_8,174,178,255,152 +Mountain breeze,light.hue_9,207,255,157,217 +Mountain breeze,light.hue_10,255,215,200,152 +Read,light.hue_1,255,174,104,255 +Read,light.hue_2,255,173,102,255 +Read,light.hue_3,255,173,102,255 +Read,light.hue_4,255,173,102,255 +Read,light.hue_5,255,174,104,255 +Read,light.hue_6,255,181,117,233 +Read,light.hue_7,255,173,102,255 +Read,light.hue_8,255,173,102,255 +Read,light.hue_9,255,173,102,255 +Read,light.hue_10,255,173,102,255 +Dimmed,light.hue_1,255,167,88,76 +Dimmed,light.hue_2,255,167,88,76 +Dimmed,light.hue_3,255,170,95,149 +Dimmed,light.hue_4,255,167,88,76 +Dimmed,light.hue_5,255,167,88,76 +Dimmed,light.hue_6,255,167,88,76 +Dimmed,light.hue_7,255,167,88,76 +Dimmed,light.hue_8,255,167,88,76 +Dimmed,light.hue_9,255,167,88,76 +Dimmed,light.hue_10,255,167,88,76 +Narcissa,light.hue_1,255,213,79,255 +Narcissa,light.hue_2,255,213,79,255 +Narcissa,light.hue_3,255,170,42,230 +Narcissa,light.hue_4,255,140,30,230 +Narcissa,light.hue_5,255,231,99,230 +Narcissa,light.hue_6,255,231,99,230 +Narcissa,light.hue_7,255,140,30,230 +Narcissa,light.hue_8,255,211,64,230 +Narcissa,light.hue_9,255,211,64,230 +Narcissa,light.hue_10,255,207,106,130 +Crocus,light.hue_1,229,255,116,255 +Crocus,light.hue_2,203,190,255,204 +Crocus,light.hue_3,229,255,116,255 +Crocus,light.hue_4,203,190,255,204 +Crocus,light.hue_5,255,212,159,204 +Crocus,light.hue_6,229,255,116,255 +Crocus,light.hue_7,255,212,159,204 +Crocus,light.hue_8,230,255,117,204 +Crocus,light.hue_9,255,212,159,204 +Crocus,light.hue_10,255,212,159,204 +Nightlight,light.hue_1,255,161,40,0 +Nightlight,light.hue_2,255,161,40,0 +Nightlight,light.hue_3,255,161,40,0 +Nightlight,light.hue_4,255,161,40,0 +Nightlight,light.hue_5,255,161,40,0 +Nightlight,light.hue_6,255,161,40,0 +Nightlight,light.hue_7,255,161,40,0 +Nightlight,light.hue_8,255,161,40,24 +Nightlight,light.hue_9,255,161,40,0 +Nightlight,light.hue_10,255,161,40,24 +São Paulo,light.hue_1,255,252,116,192 +São Paulo,light.hue_2,255,243,79,192 +São Paulo,light.hue_3,187,255,166,192 +São Paulo,light.hue_4,187,255,166,192 +São Paulo,light.hue_5,255,243,79,192 +São Paulo,light.hue_6,105,190,255,192 +São Paulo,light.hue_7,255,252,116,192 +São Paulo,light.hue_8,94,255,187,192 +São Paulo,light.hue_9,255,252,116,192 +São Paulo,light.hue_10,94,255,187,192 +Ruby romance,light.hue_1,255,143,51,215 +Ruby romance,light.hue_2,255,121,84,115 +Ruby romance,light.hue_3,255,43,76,115 +Ruby romance,light.hue_4,255,121,84,115 +Ruby romance,light.hue_5,255,145,99,115 +Ruby romance,light.hue_6,255,78,102,115 +Ruby romance,light.hue_7,255,160,106,142 +Ruby romance,light.hue_8,255,43,76,115 +Ruby romance,light.hue_9,255,78,102,115 +Ruby romance,light.hue_10,255,143,51,215 +Sunset allure,light.hue_1,255,122,76,107 +Sunset allure,light.hue_2,255,143,51,215 +Sunset allure,light.hue_3,189,139,255,107 +Sunset allure,light.hue_4,255,168,72,107 +Sunset allure,light.hue_5,255,146,80,108 +Sunset allure,light.hue_6,255,122,76,107 +Sunset allure,light.hue_7,255,168,72,107 +Sunset allure,light.hue_8,153,124,255,107 +Sunset allure,light.hue_9,153,124,255,107 +Sunset allure,light.hue_10,255,143,51,215 +Smitten,light.hue_1,255,145,105,153 +Smitten,light.hue_2,255,145,105,153 +Smitten,light.hue_3,255,129,126,153 +Smitten,light.hue_4,255,129,126,153 +Smitten,light.hue_5,255,137,114,153 +Smitten,light.hue_6,255,149,83,153 +Smitten,light.hue_7,255,137,114,153 +Smitten,light.hue_8,255,146,208,140 +Smitten,light.hue_9,255,129,126,153 +Smitten,light.hue_10,255,139,162,153 +Promise,light.hue_1,207,255,181,128 +Promise,light.hue_2,112,183,255,128 +Promise,light.hue_3,255,137,133,128 +Promise,light.hue_4,255,172,135,128 +Promise,light.hue_5,207,255,181,128 +Promise,light.hue_6,255,172,135,128 +Promise,light.hue_7,189,137,255,128 +Promise,light.hue_8,112,183,255,128 +Promise,light.hue_9,207,255,181,128 +Promise,light.hue_10,189,137,255,128 +City of love,light.hue_1,255,192,98,67 +City of love,light.hue_2,60,162,255,115 +City of love,light.hue_3,255,136,51,115 +City of love,light.hue_4,64,0,255,115 +City of love,light.hue_5,64,0,255,115 +City of love,light.hue_6,60,162,255,115 +City of love,light.hue_7,255,180,51,115 +City of love,light.hue_8,255,192,98,67 +City of love,light.hue_9,60,162,255,115 +City of love,light.hue_10,62,91,255,115 +Lovebirds,light.hue_1,255,190,45,102 +Lovebirds,light.hue_2,255,150,33,102 +Lovebirds,light.hue_3,255,189,86,102 +Lovebirds,light.hue_4,255,189,86,102 +Lovebirds,light.hue_5,255,190,45,102 +Lovebirds,light.hue_6,255,182,78,102 +Lovebirds,light.hue_7,255,182,78,102 +Lovebirds,light.hue_8,255,153,74,102 +Lovebirds,light.hue_9,255,190,45,102 +Lovebirds,light.hue_10,255,153,74,102 +Glitz and glam,light.hue_1,255,157,60,128 +Glitz and glam,light.hue_2,255,138,52,128 +Glitz and glam,light.hue_3,255,139,184,128 +Glitz and glam,light.hue_4,255,139,184,128 +Glitz and glam,light.hue_5,255,138,52,128 +Glitz and glam,light.hue_6,255,132,225,128 +Glitz and glam,light.hue_7,255,157,60,128 +Glitz and glam,light.hue_8,255,58,205,128 +Glitz and glam,light.hue_9,255,138,52,128 +Glitz and glam,light.hue_10,255,58,205,128 +Nighttime,light.hue_1,255,115,44,25 +Nighttime,light.hue_2,255,170,66,25 +Nighttime,light.hue_3,255,143,47,25 +Nighttime,light.hue_4,255,89,44,25 +Nighttime,light.hue_5,255,115,44,25 +Nighttime,light.hue_6,255,89,44,25 +Nighttime,light.hue_7,255,170,66,25 +Nighttime,light.hue_8,255,58,59,25 +Nighttime,light.hue_9,255,143,47,16 +Nighttime,light.hue_10,255,58,59,25 +Unwind,light.hue_1,255,186,75,114 +Unwind,light.hue_2,255,160,48,114 +Unwind,light.hue_3,255,174,55,114 +Unwind,light.hue_4,255,198,96,114 +Unwind,light.hue_5,255,174,55,114 +Unwind,light.hue_6,255,198,96,114 +Unwind,light.hue_7,255,186,75,114 +Unwind,light.hue_8,255,143,53,114 +Unwind,light.hue_9,255,186,75,114 +Unwind,light.hue_10,255,143,53,114 +Shine,light.hue_1,255,193,88,255 +Shine,light.hue_2,255,221,146,255 +Shine,light.hue_3,255,212,125,255 +Shine,light.hue_4,255,193,88,255 +Shine,light.hue_5,255,221,146,255 +Shine,light.hue_6,255,212,125,255 +Shine,light.hue_7,255,202,105,255 +Shine,light.hue_8,255,182,67,255 +Shine,light.hue_9,255,183,69,232 +Shine,light.hue_10,255,182,67,255 +Sleepy,light.hue_1,255,97,68,65 +Sleepy,light.hue_2,255,176,71,65 +Sleepy,light.hue_3,255,126,57,65 +Sleepy,light.hue_4,255,97,68,65 +Sleepy,light.hue_5,255,126,57,65 +Sleepy,light.hue_6,255,192,103,180 +Sleepy,light.hue_7,255,176,71,65 +Sleepy,light.hue_8,255,83,53,65 +Sleepy,light.hue_9,255,153,62,65 +Sleepy,light.hue_10,255,83,53,65 +Storybook,light.hue_1,255,187,75,177 +Storybook,light.hue_2,255,216,135,177 +Storybook,light.hue_3,255,148,73,91 +Storybook,light.hue_4,255,216,135,177 +Storybook,light.hue_5,255,207,115,177 +Storybook,light.hue_6,255,198,97,177 +Storybook,light.hue_7,255,198,97,177 +Storybook,light.hue_8,255,172,56,177 +Storybook,light.hue_9,255,187,75,177 +Storybook,light.hue_10,255,172,56,177 +Arise,light.hue_1,255,224,167,255 +Arise,light.hue_2,255,245,225,255 +Arise,light.hue_3,255,238,204,255 +Arise,light.hue_4,255,231,186,255 +Arise,light.hue_5,255,231,186,255 +Arise,light.hue_6,255,245,225,255 +Arise,light.hue_7,255,238,204,255 +Arise,light.hue_8,255,220,143,255 +Arise,light.hue_9,255,224,167,255 +Arise,light.hue_10,255,184,79,195 +Baby's breath,light.hue_1,218,253,255,255 +Baby's breath,light.hue_2,218,253,255,255 +Baby's breath,light.hue_3,222,225,255,255 +Baby's breath,light.hue_4,219,255,215,255 +Baby's breath,light.hue_5,255,230,237,255 +Baby's breath,light.hue_6,222,225,255,255 +Baby's breath,light.hue_7,255,230,237,255 +Baby's breath,light.hue_8,241,255,198,255 +Baby's breath,light.hue_9,222,225,255,255 +Baby's breath,light.hue_10,241,255,198,255 +Malibu pink,light.hue_1,249,105,255,204 +Malibu pink,light.hue_2,61,143,255,204 +Malibu pink,light.hue_3,63,78,255,204 +Malibu pink,light.hue_4,255,112,189,204 +Malibu pink,light.hue_5,255,121,108,204 +Malibu pink,light.hue_6,255,121,108,204 +Malibu pink,light.hue_7,255,112,189,204 +Malibu pink,light.hue_8,61,143,255,204 +Malibu pink,light.hue_9,61,143,255,204 +Malibu pink,light.hue_10,63,78,255,204 +Rio,light.hue_1,255,152,84,204 +Rio,light.hue_2,255,109,133,204 +Rio,light.hue_3,255,82,133,204 +Rio,light.hue_4,255,152,84,204 +Rio,light.hue_5,255,219,80,204 +Rio,light.hue_6,255,109,133,204 +Rio,light.hue_7,255,219,80,204 +Rio,light.hue_8,165,83,255,204 +Rio,light.hue_9,255,152,84,204 +Rio,light.hue_10,165,83,255,204 +Ibiza,light.hue_1,255,213,75,125 +Ibiza,light.hue_2,255,146,59,125 +Ibiza,light.hue_3,255,146,59,125 +Ibiza,light.hue_4,255,236,103,125 +Ibiza,light.hue_5,255,236,103,125 +Ibiza,light.hue_6,255,213,75,125 +Ibiza,light.hue_7,255,174,71,125 +Ibiza,light.hue_8,255,127,56,125 +Ibiza,light.hue_9,255,127,56,125 +Ibiza,light.hue_10,255,127,56,125 +Tokyo,light.hue_1,255,43,120,120 +Tokyo,light.hue_2,92,8,255,120 +Tokyo,light.hue_3,61,144,255,120 +Tokyo,light.hue_4,61,144,255,120 +Tokyo,light.hue_5,255,161,37,120 +Tokyo,light.hue_6,255,161,37,120 +Tokyo,light.hue_7,92,8,255,120 +Tokyo,light.hue_8,169,25,255,120 +Tokyo,light.hue_9,169,25,255,120 +Tokyo,light.hue_10,255,43,120,120 +Fairfax,light.hue_1,255,152,148,158 +Fairfax,light.hue_2,114,186,255,158 +Fairfax,light.hue_3,255,43,42,158 +Fairfax,light.hue_4,255,91,81,158 +Fairfax,light.hue_5,255,152,148,158 +Fairfax,light.hue_6,255,91,81,158 +Fairfax,light.hue_7,255,43,42,158 +Fairfax,light.hue_8,61,137,255,147 +Fairfax,light.hue_9,255,91,81,158 +Fairfax,light.hue_10,59,196,255,158 +Cancun,light.hue_1,255,101,137,204 +Cancun,light.hue_2,255,123,25,204 +Cancun,light.hue_3,255,193,55,204 +Cancun,light.hue_4,255,136,58,181 +Cancun,light.hue_5,255,123,25,204 +Cancun,light.hue_6,255,74,11,204 +Cancun,light.hue_7,255,101,137,204 +Cancun,light.hue_8,255,58,98,204 +Cancun,light.hue_9,255,101,137,204 +Cancun,light.hue_10,255,58,98,204 +Chinatown,light.hue_1,255,88,98,192 +Chinatown,light.hue_2,255,68,76,153 +Chinatown,light.hue_3,255,157,35,153 +Chinatown,light.hue_4,255,121,101,153 +Chinatown,light.hue_5,255,121,101,153 +Chinatown,light.hue_6,255,68,76,153 +Chinatown,light.hue_7,255,43,46,153 +Chinatown,light.hue_8,255,51,67,153 +Chinatown,light.hue_9,255,47,56,153 +Chinatown,light.hue_10,255,51,67,153 +Osaka,light.hue_1,255,133,183,92 +Osaka,light.hue_2,255,96,96,92 +Osaka,light.hue_3,255,118,23,92 +Osaka,light.hue_4,255,187,69,92 +Osaka,light.hue_5,255,187,69,92 +Osaka,light.hue_6,255,96,96,92 +Osaka,light.hue_7,255,118,23,92 +Osaka,light.hue_8,255,65,138,92 +Osaka,light.hue_9,255,65,138,92 +Osaka,light.hue_10,255,65,138,92 +Motown,light.hue_1,57,230,255,135 +Motown,light.hue_2,64,67,255,135 +Motown,light.hue_3,64,67,255,135 +Motown,light.hue_4,60,169,255,135 +Motown,light.hue_5,64,0,255,135 +Motown,light.hue_6,61,142,255,135 +Motown,light.hue_7,57,230,255,135 +Motown,light.hue_8,60,169,255,135 +Motown,light.hue_9,64,0,255,135 +Motown,light.hue_10,64,0,255,135 +Galaxy,light.hue_1,255,125,162,63 +Galaxy,light.hue_2,255,147,185,63 +Galaxy,light.hue_3,255,147,185,63 +Galaxy,light.hue_4,255,125,162,63 +Galaxy,light.hue_5,255,134,116,63 +Galaxy,light.hue_6,239,125,255,63 +Galaxy,light.hue_7,255,134,116,63 +Galaxy,light.hue_8,255,111,169,63 +Galaxy,light.hue_9,255,111,169,63 +Galaxy,light.hue_10,255,111,169,63 +Blood moon,light.hue_1,97,115,255,51 +Blood moon,light.hue_2,97,115,255,51 +Blood moon,light.hue_3,255,185,210,51 +Blood moon,light.hue_4,255,174,147,51 +Blood moon,light.hue_5,214,176,255,51 +Blood moon,light.hue_6,214,176,255,51 +Blood moon,light.hue_7,255,185,210,51 +Blood moon,light.hue_8,72,60,255,51 +Blood moon,light.hue_9,214,176,255,51 +Blood moon,light.hue_10,72,60,255,51 +Moonlight,light.hue_1,88,135,255,51 +Moonlight,light.hue_2,88,135,255,51 +Moonlight,light.hue_3,255,231,223,51 +Moonlight,light.hue_4,131,164,255,51 +Moonlight,light.hue_5,62,104,255,51 +Moonlight,light.hue_6,131,164,255,51 +Moonlight,light.hue_7,64,0,255,51 +Moonlight,light.hue_8,62,104,255,51 +Moonlight,light.hue_9,88,135,255,51 +Moonlight,light.hue_10,64,0,255,51 +Starlight,light.hue_1,255,200,150,61 +Starlight,light.hue_2,255,182,225,61 +Starlight,light.hue_3,215,147,255,61 +Starlight,light.hue_4,215,147,255,61 +Starlight,light.hue_5,255,200,150,61 +Starlight,light.hue_6,148,127,255,54 +Starlight,light.hue_7,255,189,176,61 +Starlight,light.hue_8,137,107,255,61 +Starlight,light.hue_9,255,189,176,61 +Starlight,light.hue_10,137,107,255,61 +Arctic aurora,light.hue_1,110,255,185,67 +Arctic aurora,light.hue_2,110,255,185,67 +Arctic aurora,light.hue_3,65,136,255,67 +Arctic aurora,light.hue_4,154,255,220,67 +Arctic aurora,light.hue_5,109,250,255,67 +Arctic aurora,light.hue_6,109,250,255,67 +Arctic aurora,light.hue_7,154,255,220,67 +Arctic aurora,light.hue_8,63,83,255,67 +Arctic aurora,light.hue_9,154,255,220,67 +Arctic aurora,light.hue_10,63,83,255,67 +Nebula,light.hue_1,179,127,255,61 +Nebula,light.hue_2,179,127,255,61 +Nebula,light.hue_3,255,181,230,61 +Nebula,light.hue_4,151,162,255,61 +Nebula,light.hue_5,255,181,230,61 +Nebula,light.hue_6,222,168,255,61 +Nebula,light.hue_7,222,168,255,61 +Nebula,light.hue_8,108,21,255,61 +Nebula,light.hue_9,179,127,255,61 +Nebula,light.hue_10,108,21,255,61 +Ocean dawn,light.hue_1,255,214,146,215 +Ocean dawn,light.hue_2,255,199,148,215 +Ocean dawn,light.hue_3,200,184,255,215 +Ocean dawn,light.hue_4,255,214,146,215 +Ocean dawn,light.hue_5,255,199,148,215 +Ocean dawn,light.hue_6,255,204,174,215 +Ocean dawn,light.hue_7,255,204,174,215 +Ocean dawn,light.hue_8,200,184,255,215 +Ocean dawn,light.hue_9,255,214,146,215 +Ocean dawn,light.hue_10,255,208,222,215 +Sunday morning,light.hue_1,255,230,199,173 +Sunday morning,light.hue_2,255,230,123,173 +Sunday morning,light.hue_3,255,230,123,173 +Sunday morning,light.hue_4,255,223,141,173 +Sunday morning,light.hue_5,255,223,141,173 +Sunday morning,light.hue_6,255,223,170,173 +Sunday morning,light.hue_7,255,230,199,173 +Sunday morning,light.hue_8,231,255,148,173 +Sunday morning,light.hue_9,255,230,199,173 +Sunday morning,light.hue_10,231,255,148,173 +Spring blossom,light.hue_1,255,204,235,153 +Spring blossom,light.hue_2,255,179,231,153 +Spring blossom,light.hue_3,255,179,231,153 +Spring blossom,light.hue_4,255,223,233,153 +Spring blossom,light.hue_5,255,223,233,153 +Spring blossom,light.hue_6,255,145,125,153 +Spring blossom,light.hue_7,255,204,235,153 +Spring blossom,light.hue_8,255,127,192,153 +Spring blossom,light.hue_9,255,223,233,153 +Spring blossom,light.hue_10,255,127,192,153 +Lake mist,light.hue_1,170,197,255,165 +Lake mist,light.hue_2,255,207,206,165 +Lake mist,light.hue_3,255,207,206,165 +Lake mist,light.hue_4,255,194,172,165 +Lake mist,light.hue_5,255,213,173,165 +Lake mist,light.hue_6,255,194,172,165 +Lake mist,light.hue_7,255,213,173,165 +Lake mist,light.hue_8,146,165,255,165 +Lake mist,light.hue_9,170,197,255,165 +Lake mist,light.hue_10,146,165,255,165 +Frosty dawn,light.hue_1,255,240,155,204 +Frosty dawn,light.hue_2,255,187,131,204 +Frosty dawn,light.hue_3,255,214,155,204 +Frosty dawn,light.hue_4,255,202,148,204 +Frosty dawn,light.hue_5,255,187,131,204 +Frosty dawn,light.hue_6,255,240,155,204 +Frosty dawn,light.hue_7,255,214,155,204 +Frosty dawn,light.hue_8,253,255,156,204 +Frosty dawn,light.hue_9,255,214,155,204 +Frosty dawn,light.hue_10,253,255,156,204 +Emerald isle,light.hue_1,209,255,190,192 +Emerald isle,light.hue_2,255,209,106,192 +Emerald isle,light.hue_3,255,209,106,192 +Emerald isle,light.hue_4,203,247,255,192 +Emerald isle,light.hue_5,255,244,163,192 +Emerald isle,light.hue_6,203,247,255,192 +Emerald isle,light.hue_7,255,244,163,192 +Emerald isle,light.hue_8,163,255,197,192 +Emerald isle,light.hue_9,203,247,255,192 +Emerald isle,light.hue_10,163,255,197,192 +Beginnings,light.hue_1,110,132,255,51 +Beginnings,light.hue_2,255,182,251,51 +Beginnings,light.hue_3,255,182,251,51 +Beginnings,light.hue_4,184,154,255,51 +Beginnings,light.hue_5,110,132,255,51 +Beginnings,light.hue_6,77,131,255,51 +Beginnings,light.hue_7,184,154,255,51 +Beginnings,light.hue_8,67,126,255,51 +Beginnings,light.hue_9,184,154,255,51 +Beginnings,light.hue_10,67,126,255,51 +Horizon,light.hue_1,255,168,89,128 +Horizon,light.hue_2,190,165,255,128 +Horizon,light.hue_3,98,163,255,128 +Horizon,light.hue_4,255,168,89,128 +Horizon,light.hue_5,98,163,255,128 +Horizon,light.hue_6,255,181,74,128 +Horizon,light.hue_7,190,165,255,128 +Horizon,light.hue_8,88,164,255,128 +Horizon,light.hue_9,88,164,255,128 +Horizon,light.hue_10,88,164,255,128 +Sunflare,light.hue_1,177,194,255,204 +Sunflare,light.hue_2,255,190,90,204 +Sunflare,light.hue_3,177,194,255,204 +Sunflare,light.hue_4,255,197,102,204 +Sunflare,light.hue_5,255,197,102,204 +Sunflare,light.hue_6,255,185,97,151 +Sunflare,light.hue_7,255,202,141,204 +Sunflare,light.hue_8,125,185,255,204 +Sunflare,light.hue_9,177,194,255,204 +Sunflare,light.hue_10,125,185,255,204 +First light,light.hue_1,255,172,196,77 +First light,light.hue_2,222,177,255,77 +First light,light.hue_3,255,133,211,144 +First light,light.hue_4,255,172,196,77 +First light,light.hue_5,133,150,255,77 +First light,light.hue_6,255,168,70,77 +First light,light.hue_7,133,150,255,77 +First light,light.hue_8,76,153,255,77 +First light,light.hue_9,76,153,255,77 +First light,light.hue_10,76,153,255,77 +Valley dawn,light.hue_1,255,184,96,178 +Valley dawn,light.hue_2,255,200,200,178 +Valley dawn,light.hue_3,255,200,200,178 +Valley dawn,light.hue_4,255,184,96,178 +Valley dawn,light.hue_5,140,171,255,178 +Valley dawn,light.hue_6,255,191,92,178 +Valley dawn,light.hue_7,140,171,255,178 +Valley dawn,light.hue_8,105,175,255,178 +Valley dawn,light.hue_9,255,184,96,178 +Valley dawn,light.hue_10,91,163,255,123 +Emerald flutter,light.hue_1,79,236,255,102 +Emerald flutter,light.hue_2,218,255,198,102 +Emerald flutter,light.hue_3,61,198,255,102 +Emerald flutter,light.hue_4,139,255,250,102 +Emerald flutter,light.hue_5,79,236,255,102 +Emerald flutter,light.hue_6,139,255,250,102 +Emerald flutter,light.hue_7,140,171,255,102 +Emerald flutter,light.hue_8,60,161,255,102 +Emerald flutter,light.hue_9,139,255,250,102 +Emerald flutter,light.hue_10,60,161,255,102 +Memento,light.hue_1,82,78,255,102 +Memento,light.hue_2,88,130,255,102 +Memento,light.hue_3,88,130,255,102 +Memento,light.hue_4,255,206,51,102 +Memento,light.hue_5,100,64,255,102 +Memento,light.hue_6,255,206,51,102 +Memento,light.hue_7,82,78,255,102 +Memento,light.hue_8,64,97,255,102 +Memento,light.hue_9,82,78,255,102 +Memento,light.hue_10,64,97,255,102 +Resplendent,light.hue_1,219,131,255,77 +Resplendent,light.hue_2,255,160,113,77 +Resplendent,light.hue_3,255,117,221,77 +Resplendent,light.hue_4,255,142,168,77 +Resplendent,light.hue_5,219,131,255,77 +Resplendent,light.hue_6,255,160,113,77 +Resplendent,light.hue_7,255,142,168,77 +Resplendent,light.hue_8,150,91,255,77 +Resplendent,light.hue_9,219,131,255,77 +Resplendent,light.hue_10,150,91,255,77 +Scarlet dream,light.hue_1,255,150,214,77 +Scarlet dream,light.hue_2,255,103,114,77 +Scarlet dream,light.hue_3,255,101,150,77 +Scarlet dream,light.hue_4,255,117,87,77 +Scarlet dream,light.hue_5,255,150,214,77 +Scarlet dream,light.hue_6,255,117,87,77 +Scarlet dream,light.hue_7,255,111,126,77 +Scarlet dream,light.hue_8,255,101,150,77 +Scarlet dream,light.hue_9,255,150,214,77 +Scarlet dream,light.hue_10,255,127,181,77 +Amethyst valley,light.hue_1,210,183,255,240 +Amethyst valley,light.hue_2,186,153,255,240 +Amethyst valley,light.hue_3,163,137,255,240 +Amethyst valley,light.hue_4,186,153,255,240 +Amethyst valley,light.hue_5,224,153,255,163 +Amethyst valley,light.hue_6,199,167,255,240 +Amethyst valley,light.hue_7,210,183,255,240 +Amethyst valley,light.hue_8,143,113,255,240 +Amethyst valley,light.hue_9,186,153,255,240 +Amethyst valley,light.hue_10,143,113,255,240 +Hazy days,light.hue_1,255,217,123,222 +Hazy days,light.hue_2,255,225,143,222 +Hazy days,light.hue_3,255,201,92,222 +Hazy days,light.hue_4,255,209,110,222 +Hazy days,light.hue_5,255,201,92,222 +Hazy days,light.hue_6,255,217,123,222 +Hazy days,light.hue_7,255,225,143,222 +Hazy days,light.hue_8,255,124,230,235 +Hazy days,light.hue_9,255,225,143,222 +Hazy days,light.hue_10,255,194,81,222 +Autumn gold,light.hue_1,255,221,119,204 +Autumn gold,light.hue_2,255,203,118,204 +Autumn gold,light.hue_3,255,210,134,204 +Autumn gold,light.hue_4,255,219,101,204 +Autumn gold,light.hue_5,255,203,118,204 +Autumn gold,light.hue_6,255,221,119,204 +Autumn gold,light.hue_7,255,219,101,204 +Autumn gold,light.hue_8,255,194,119,204 +Autumn gold,light.hue_9,255,219,101,204 +Autumn gold,light.hue_10,255,194,119,204 +Winter mountain,light.hue_1,182,236,255,225 +Winter mountain,light.hue_2,204,242,255,225 +Winter mountain,light.hue_3,224,248,255,225 +Winter mountain,light.hue_4,182,236,255,225 +Winter mountain,light.hue_5,204,242,255,225 +Winter mountain,light.hue_6,178,235,255,225 +Winter mountain,light.hue_7,224,248,255,225 +Winter mountain,light.hue_8,167,231,255,225 +Winter mountain,light.hue_9,204,242,255,225 +Winter mountain,light.hue_10,167,231,255,225 +Misty ridge,light.hue_1,255,228,240,204 +Misty ridge,light.hue_2,255,208,210,204 +Misty ridge,light.hue_3,255,200,197,204 +Misty ridge,light.hue_4,255,217,221,204 +Misty ridge,light.hue_5,255,228,240,204 +Misty ridge,light.hue_6,255,217,221,204 +Misty ridge,light.hue_7,255,208,210,204 +Misty ridge,light.hue_8,255,194,184,204 +Misty ridge,light.hue_9,255,196,190,204 +Misty ridge,light.hue_10,255,194,184,204 +Midsummer sun,light.hue_1,255,241,136,252 +Midsummer sun,light.hue_2,255,204,84,252 +Midsummer sun,light.hue_3,255,219,107,252 +Midsummer sun,light.hue_4,255,190,189,215 +Midsummer sun,light.hue_5,255,229,120,252 +Midsummer sun,light.hue_6,255,219,107,252 +Midsummer sun,light.hue_7,255,204,84,252 +Midsummer sun,light.hue_8,255,181,63,252 +Midsummer sun,light.hue_9,255,204,84,252 +Midsummer sun,light.hue_10,255,181,63,252 +Spring lake,light.hue_1,255,241,136,242 +Spring lake,light.hue_2,247,255,225,242 +Spring lake,light.hue_3,247,255,225,242 +Spring lake,light.hue_4,240,255,203,242 +Spring lake,light.hue_5,235,255,210,242 +Spring lake,light.hue_6,240,255,203,242 +Spring lake,light.hue_7,235,255,210,242 +Spring lake,light.hue_8,220,255,176,242 +Spring lake,light.hue_9,235,255,210,242 +Spring lake,light.hue_10,220,255,176,242 +Midwinter,light.hue_1,187,209,255,227 +Midwinter,light.hue_2,161,196,255,227 +Midwinter,light.hue_3,129,154,255,227 +Midwinter,light.hue_4,187,209,255,227 +Midwinter,light.hue_5,143,173,255,227 +Midwinter,light.hue_6,183,191,255,227 +Midwinter,light.hue_7,183,191,255,227 +Midwinter,light.hue_8,143,173,255,227 +Midwinter,light.hue_9,161,196,255,227 +Midwinter,light.hue_10,129,154,255,227 +Amber bloom,light.hue_1,255,168,75,204 +Amber bloom,light.hue_2,255,154,35,204 +Amber bloom,light.hue_3,255,171,121,204 +Amber bloom,light.hue_4,255,154,35,204 +Amber bloom,light.hue_5,255,171,121,204 +Amber bloom,light.hue_6,255,168,75,204 +Amber bloom,light.hue_7,255,138,30,204 +Amber bloom,light.hue_8,255,109,56,204 +Amber bloom,light.hue_9,255,138,30,204 +Amber bloom,light.hue_10,255,109,56,204 +Painted sky,light.hue_1,255,138,48,204 +Painted sky,light.hue_2,255,119,24,204 +Painted sky,light.hue_3,255,155,225,204 +Painted sky,light.hue_4,255,155,225,204 +Painted sky,light.hue_5,255,173,142,204 +Painted sky,light.hue_6,209,127,255,204 +Painted sky,light.hue_7,255,173,142,204 +Painted sky,light.hue_8,136,94,255,204 +Painted sky,light.hue_9,136,94,255,204 +Painted sky,light.hue_10,136,94,255,204 +Orange fields,light.hue_1,255,224,55,204 +Orange fields,light.hue_2,255,127,77,204 +Orange fields,light.hue_3,255,203,49,204 +Orange fields,light.hue_4,255,224,55,204 +Orange fields,light.hue_5,255,156,35,204 +Orange fields,light.hue_6,255,133,90,204 +Orange fields,light.hue_7,255,203,49,204 +Orange fields,light.hue_8,194,255,65,204 +Orange fields,light.hue_9,255,156,35,204 +Orange fields,light.hue_10,194,255,65,204 +Blue Planet,light.hue_1,46,255,216,138 +Blue Planet,light.hue_2,20,255,126,138 +Blue Planet,light.hue_3,110,255,238,138 +Blue Planet,light.hue_4,60,155,255,138 +Blue Planet,light.hue_5,110,255,238,138 +Blue Planet,light.hue_6,186,255,99,138 +Blue Planet,light.hue_7,60,155,255,138 +Blue Planet,light.hue_8,20,255,126,138 +Blue Planet,light.hue_9,255,156,35,204 +Blue Planet,light.hue_10,46,255,216,138 +Lily,light.hue_1,255,173,142,204 +Lily,light.hue_2,255,167,66,204 +Lily,light.hue_3,255,149,34,204 +Lily,light.hue_4,255,122,145,204 +Lily,light.hue_5,255,149,34,204 +Lily,light.hue_6,255,167,66,204 +Lily,light.hue_7,255,173,142,204 +Lily,light.hue_8,255,75,124,204 +Lily,light.hue_9,255,173,142,204 +Lily,light.hue_10,255,75,124,204 +Winter beauty,light.hue_1,255,172,65,204 +Winter beauty,light.hue_2,255,239,125,204 +Winter beauty,light.hue_3,110,255,165,204 +Winter beauty,light.hue_4,195,255,133,204 +Winter beauty,light.hue_5,255,172,65,204 +Winter beauty,light.hue_6,195,255,133,204 +Winter beauty,light.hue_7,255,239,125,204 +Winter beauty,light.hue_8,95,235,255,204 +Winter beauty,light.hue_9,95,235,255,204 +Winter beauty,light.hue_10,95,235,255,204 +Forest adventure,light.hue_1,162,255,69,204 +Forest adventure,light.hue_2,242,255,63,204 +Forest adventure,light.hue_3,62,178,255,204 +Forest adventure,light.hue_4,162,255,69,204 +Forest adventure,light.hue_5,81,255,111,204 +Forest adventure,light.hue_6,242,255,63,204 +Forest adventure,light.hue_7,81,255,111,204 +Forest adventure,light.hue_8,62,110,255,204 +Forest adventure,light.hue_9,65,184,255,204 +Forest adventure,light.hue_10,62,110,255,204 +Meriete,light.hue_1,197,132,255,204 +Meriete,light.hue_2,85,0,255,204 +Meriete,light.hue_3,85,0,255,204 +Meriete,light.hue_4,255,205,114,204 +Meriete,light.hue_5,197,132,255,204 +Meriete,light.hue_6,255,231,91,204 +Meriete,light.hue_7,125,88,255,204 +Meriete,light.hue_8,62,104,255,204 +Meriete,light.hue_9,62,104,255,204 +Meriete,light.hue_10,62,104,255,204 +Soho,light.hue_1,255,113,155,158 +Soho,light.hue_2,255,113,155,158 +Soho,light.hue_3,85,0,255,204 +Soho,light.hue_4,255,161,113,158 +Soho,light.hue_5,135,27,255,158 +Soho,light.hue_6,103,255,219,158 +Soho,light.hue_7,255,161,113,158 +Soho,light.hue_8,255,43,122,158 +Soho,light.hue_9,135,27,255,158 +Soho,light.hue_10,135,27,255,158 +Magneto,light.hue_1,68,124,255,225 +Magneto,light.hue_2,255,193,46,225 +Magneto,light.hue_3,252,255,89,225 +Magneto,light.hue_4,255,193,46,225 +Magneto,light.hue_5,82,253,255,225 +Magneto,light.hue_6,82,253,255,225 +Magneto,light.hue_7,252,255,89,225 +Magneto,light.hue_8,60,176,255,225 +Magneto,light.hue_9,255,193,46,225 +Magneto,light.hue_10,60,176,255,225 +Disturbia,light.hue_1,142,236,255,160 +Disturbia,light.hue_2,255,95,246,160 +Disturbia,light.hue_3,255,43,66,160 +Disturbia,light.hue_4,142,236,255,160 +Disturbia,light.hue_5,255,43,66,160 +Disturbia,light.hue_6,255,86,146,160 +Disturbia,light.hue_7,255,86,146,160 +Disturbia,light.hue_8,50,255,234,160 +Disturbia,light.hue_9,50,255,234,160 +Disturbia,light.hue_10,50,255,234,160 +Vapor wave,light.hue_1,95,109,255,199 +Vapor wave,light.hue_2,255,88,233,199 +Vapor wave,light.hue_3,255,88,233,199 +Vapor wave,light.hue_4,255,176,41,199 +Vapor wave,light.hue_5,255,210,90,199 +Vapor wave,light.hue_6,255,210,90,199 +Vapor wave,light.hue_7,95,109,255,199 +Vapor wave,light.hue_8,141,27,255,199 +Vapor wave,light.hue_9,255,176,41,199 +Vapor wave,light.hue_10,141,27,255,199 +Tyrell,light.hue_1,255,81,239,128 +Tyrell,light.hue_2,168,133,255,128 +Tyrell,light.hue_3,58,203,255,128 +Tyrell,light.hue_4,255,81,239,128 +Tyrell,light.hue_5,138,18,255,128 +Tyrell,light.hue_6,255,154,245,128 +Tyrell,light.hue_7,168,133,255,128 +Tyrell,light.hue_8,58,203,255,128 +Tyrell,light.hue_9,255,81,239,128 +Tyrell,light.hue_10,138,18,255,137 +Hal,light.hue_1,255,96,94,194 +Hal,light.hue_2,255,124,98,194 +Hal,light.hue_3,255,208,75,194 +Hal,light.hue_4,137,195,255,194 +Hal,light.hue_5,255,124,98,194 +Hal,light.hue_6,255,96,94,194 +Hal,light.hue_7,168,133,255,194 +Hal,light.hue_8,58,210,255,194 +Hal,light.hue_9,58,210,255,194 +Hal,light.hue_10,58,210,255,194 +Still waters,light.hue_1,255,194,198,173 +Still waters,light.hue_2,255,194,198,173 +Still waters,light.hue_3,217,165,255,173 +Still waters,light.hue_4,217,165,255,173 +Still waters,light.hue_5,255,170,187,173 +Still waters,light.hue_6,255,170,187,173 +Still waters,light.hue_7,245,180,255,173 +Still waters,light.hue_8,160,123,255,173 +Still waters,light.hue_9,245,180,255,173 +Still waters,light.hue_10,160,123,255,173 +Pensive,light.hue_1,255,151,140,143 +Pensive,light.hue_2,255,134,97,143 +Pensive,light.hue_3,148,132,255,143 +Pensive,light.hue_4,255,161,78,143 +Pensive,light.hue_5,255,179,239,143 +Pensive,light.hue_6,255,134,97,143 +Pensive,light.hue_7,255,151,140,143 +Pensive,light.hue_8,148,132,255,143 +Pensive,light.hue_9,255,151,140,143 +Pensive,light.hue_10,255,179,239,143 +Sundown,light.hue_1,255,185,115,128 +Sundown,light.hue_2,170,206,255,128 +Sundown,light.hue_3,255,185,115,128 +Sundown,light.hue_4,255,119,72,128 +Sundown,light.hue_5,255,119,72,128 +Sundown,light.hue_6,255,143,87,128 +Sundown,light.hue_7,167,171,255,130 +Sundown,light.hue_8,111,142,255,128 +Sundown,light.hue_9,255,143,87,128 +Sundown,light.hue_10,111,142,255,128 +Palm Beach,light.hue_1,255,158,143,97 +Palm Beach,light.hue_2,255,189,211,97 +Palm Beach,light.hue_3,189,242,255,97 +Palm Beach,light.hue_4,255,133,84,97 +Palm Beach,light.hue_5,255,144,158,111 +Palm Beach,light.hue_6,255,133,84,97 +Palm Beach,light.hue_7,189,242,255,97 +Palm Beach,light.hue_8,128,255,252,97 +Palm Beach,light.hue_9,255,189,211,97 +Palm Beach,light.hue_10,128,255,252,97 +Adrift,light.hue_1,123,136,255,165 +Adrift,light.hue_2,90,120,255,165 +Adrift,light.hue_3,255,148,153,165 +Adrift,light.hue_4,90,120,255,165 +Adrift,light.hue_5,255,148,153,165 +Adrift,light.hue_6,188,151,255,165 +Adrift,light.hue_7,123,136,255,165 +Adrift,light.hue_8,82,157,255,145 +Adrift,light.hue_9,62,102,255,165 +Adrift,light.hue_10,62,102,255,165 +Majestic morning,light.hue_1,255,241,159,145 +Majestic morning,light.hue_2,255,178,142,145 +Majestic morning,light.hue_3,255,178,142,145 +Majestic morning,light.hue_4,90,120,255,145 +Majestic morning,light.hue_5,255,207,95,145 +Majestic morning,light.hue_6,255,145,93,145 +Majestic morning,light.hue_7,255,241,159,145 +Majestic morning,light.hue_8,105,9,255,145 +Majestic morning,light.hue_9,255,241,159,145 +Majestic morning,light.hue_10,105,9,255,145 +Blue lagoon,light.hue_1,215,255,216,128 +Blue lagoon,light.hue_2,255,182,141,128 +Blue lagoon,light.hue_3,170,255,246,128 +Blue lagoon,light.hue_4,255,251,149,128 +Blue lagoon,light.hue_5,255,251,149,128 +Blue lagoon,light.hue_6,170,255,246,128 +Blue lagoon,light.hue_7,215,255,216,128 +Blue lagoon,light.hue_8,62,244,255,128 +Blue lagoon,light.hue_9,170,255,246,128 +Blue lagoon,light.hue_10,62,244,255,128 +Lake Placid,light.hue_1,255,190,177,77 +Lake Placid,light.hue_2,255,153,119,77 +Lake Placid,light.hue_3,255,174,146,77 +Lake Placid,light.hue_4,255,190,177,77 +Lake Placid,light.hue_5,167,194,255,77 +Lake Placid,light.hue_6,167,194,255,77 +Lake Placid,light.hue_7,255,174,146,77 +Lake Placid,light.hue_8,113,158,255,77 +Lake Placid,light.hue_9,255,153,119,77 +Lake Placid,light.hue_10,113,158,255,77 +Zandvoort,light.hue_1,255,189,74,216 +Zandvoort,light.hue_2,254,255,155,216 +Zandvoort,light.hue_3,148,200,255,216 +Zandvoort,light.hue_4,255,227,148,187 +Zandvoort,light.hue_5,148,200,255,216 +Zandvoort,light.hue_6,255,146,93,216 +Zandvoort,light.hue_7,255,146,93,216 +Zandvoort,light.hue_8,71,156,255,216 +Zandvoort,light.hue_9,255,146,93,216 +Zandvoort,light.hue_10,71,156,255,216 +Miami,light.hue_1,255,147,89,202 +Miami,light.hue_2,255,95,139,192 +Miami,light.hue_3,255,160,89,192 +Miami,light.hue_4,255,160,89,192 +Miami,light.hue_5,255,95,139,192 +Miami,light.hue_6,122,174,255,192 +Miami,light.hue_7,255,132,98,192 +Miami,light.hue_8,63,232,255,192 +Miami,light.hue_9,255,160,89,192 +Miami,light.hue_10,63,232,255,192 +Bahrain,light.hue_1,255,171,69,151 +Bahrain,light.hue_2,255,87,95,182 +Bahrain,light.hue_3,83,213,255,151 +Bahrain,light.hue_4,255,135,52,151 +Bahrain,light.hue_5,255,192,112,151 +Bahrain,light.hue_6,255,192,112,151 +Bahrain,light.hue_7,152,223,255,151 +Bahrain,light.hue_8,83,213,255,151 +Bahrain,light.hue_9,83,213,255,151 +Bahrain,light.hue_10,152,223,255,151 +Singapore,light.hue_1,255,230,98,192 +Singapore,light.hue_2,255,141,177,192 +Singapore,light.hue_3,78,97,255,192 +Singapore,light.hue_4,255,230,98,192 +Singapore,light.hue_5,137,255,195,192 +Singapore,light.hue_6,137,255,195,192 +Singapore,light.hue_7,255,141,177,192 +Singapore,light.hue_8,78,97,255,192 +Singapore,light.hue_9,137,255,195,192 +Singapore,light.hue_10,84,188,255,192 +Silverstone,light.hue_1,255,149,115,152 +Silverstone,light.hue_2,158,133,255,152 +Silverstone,light.hue_3,255,62,111,152 +Silverstone,light.hue_4,255,149,115,152 +Silverstone,light.hue_5,255,167,91,152 +Silverstone,light.hue_6,144,192,255,152 +Silverstone,light.hue_7,255,167,91,152 +Silverstone,light.hue_8,255,62,111,152 +Silverstone,light.hue_9,255,62,111,152 +Silverstone,light.hue_10,158,133,255,152 +Suzuka,light.hue_1,255,170,165,164 +Suzuka,light.hue_2,255,241,126,164 +Suzuka,light.hue_3,255,124,178,164 +Suzuka,light.hue_4,237,122,255,164 +Suzuka,light.hue_5,255,170,165,164 +Suzuka,light.hue_6,237,122,255,164 +Suzuka,light.hue_7,255,241,126,164 +Suzuka,light.hue_8,137,56,255,164 +Suzuka,light.hue_9,255,241,126,164 +Suzuka,light.hue_10,137,56,255,164 \ No newline at end of file diff --git a/src/openrgb_hue/gradient.py b/src/openrgb_hue/gradient.py new file mode 100644 index 0000000..26d2d13 --- /dev/null +++ b/src/openrgb_hue/gradient.py @@ -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)] diff --git a/src/openrgb_hue/mapping.py b/src/openrgb_hue/mapping.py new file mode 100644 index 0000000..68f8189 --- /dev/null +++ b/src/openrgb_hue/mapping.py @@ -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 diff --git a/src/openrgb_hue/scenes.py b/src/openrgb_hue/scenes.py new file mode 100644 index 0000000..22c3ea3 --- /dev/null +++ b/src/openrgb_hue/scenes.py @@ -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 diff --git a/src/openrgb_hue/targets.py b/src/openrgb_hue/targets.py new file mode 100644 index 0000000..f070179 --- /dev/null +++ b/src/openrgb_hue/targets.py @@ -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 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..4cacd5f --- /dev/null +++ b/tests/conftest.py @@ -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] diff --git a/tests/test_animation.py b/tests/test_animation.py new file mode 100644 index 0000000..d15a150 --- /dev/null +++ b/tests/test_animation.py @@ -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) diff --git a/tests/test_cli.py b/tests/test_cli.py new file mode 100644 index 0000000..2cb7a39 --- /dev/null +++ b/tests/test_cli.py @@ -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 diff --git a/tests/test_color.py b/tests/test_color.py new file mode 100644 index 0000000..2af0e23 --- /dev/null +++ b/tests/test_color.py @@ -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) diff --git a/tests/test_gradient.py b/tests/test_gradient.py new file mode 100644 index 0000000..ff55924 --- /dev/null +++ b/tests/test_gradient.py @@ -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) == [] diff --git a/tests/test_mapping.py b/tests/test_mapping.py new file mode 100644 index 0000000..486f414 --- /dev/null +++ b/tests/test_mapping.py @@ -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()) diff --git a/tests/test_scenes.py b/tests/test_scenes.py new file mode 100644 index 0000000..f4efac8 --- /dev/null +++ b/tests/test_scenes.py @@ -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()