Add openrgb-hue: Hue scene gradients for OpenRGB lights
Applies gradients derived from 103 bundled Philips Hue scenes to OpenRGB-controlled LEDs, with six mapping modes (sequence, per-device, per-zone, matrix, mirror, shuffle) and five animation modes (static, scroll, pingpong, pulse, wave). Typer/Rich CLI with dry-run preview, device/zone targeting, and a scenes-update command to refresh the bundled dataset from its source gist. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,9 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.coverage
|
||||
htmlcov/
|
||||
build/
|
||||
dist/
|
||||
.venv/
|
||||
@@ -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.
|
||||
@@ -0,0 +1,222 @@
|
||||
# openrgb-hue
|
||||
|
||||
Turn Philips Hue scene palettes into gradients and animations on your
|
||||
[OpenRGB](https://openrgb.org/)-controlled lights — motherboards, GPUs, LED
|
||||
strips, keyboards, and anything else OpenRGB can see.
|
||||
|
||||
## Features
|
||||
|
||||
- 103 bundled Philips Hue scenes, each turned into a smooth, cyclic gradient
|
||||
(RGB or HSV interpolation).
|
||||
- Six **mapping modes** for how the gradient lands on your actual LEDs:
|
||||
`sequence`, `per-device`, `per-zone`, `matrix` (with directional variants
|
||||
for keyboards and other grid devices), `mirror`, `shuffle`.
|
||||
- Five **animation modes**: `static`, `scroll`, `pingpong`, `pulse`, `wave`.
|
||||
- A polished terminal UI (via [Typer](https://typer.tiangolo.com/) +
|
||||
[Rich](https://rich.readthedocs.io/)) with true-color gradient/scene
|
||||
swatch previews.
|
||||
- `--dry-run` on `apply`/`animate` renders a full preview against a
|
||||
synthetic LED layout — no OpenRGB connection required.
|
||||
- Works fully offline; refresh the bundled scene dataset on demand with
|
||||
`openrgb-hue scenes update`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Python 3.10+
|
||||
- [OpenRGB](https://openrgb.org/) installed and running, with its **SDK
|
||||
Server started** (Settings → SDK Server → Start Server). Default:
|
||||
`127.0.0.1:6742`.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
pip install openrgb-hue
|
||||
```
|
||||
|
||||
From source, for development:
|
||||
|
||||
```bash
|
||||
git clone <this-repo>
|
||||
cd openrgb-hue
|
||||
pip install -e ".[dev]"
|
||||
```
|
||||
|
||||
## Quickstart
|
||||
|
||||
```bash
|
||||
# See what OpenRGB can see.
|
||||
openrgb-hue devices
|
||||
|
||||
# Browse the bundled Hue scenes.
|
||||
openrgb-hue scenes list
|
||||
openrgb-hue scenes show "Tropical twilight"
|
||||
|
||||
# Apply a scene as a static gradient across every connected LED.
|
||||
openrgb-hue apply "Tropical twilight"
|
||||
|
||||
# ...or animate it, scrolling across your lights until you press Ctrl+C.
|
||||
openrgb-hue animate "Tropical twilight" --mode scroll
|
||||
```
|
||||
|
||||
## Concepts
|
||||
|
||||
### From scene to gradient
|
||||
|
||||
Each bundled Hue scene is a small discrete color palette (usually 2-6
|
||||
distinct colors) recorded across up to 10 lights. `openrgb-hue` turns that
|
||||
into a gradient by:
|
||||
|
||||
1. Reading each light's color in its recorded order.
|
||||
2. Optionally scaling each color by that light's recorded brightness
|
||||
(`--brightness`/`--no-brightness`, on by default).
|
||||
3. Collapsing consecutive duplicate colors into one gradient stop
|
||||
(`--dedupe`/`--no-dedupe`, on by default) so flat repeated segments don't
|
||||
waste gradient space.
|
||||
4. Spacing the resulting colors evenly across `[0, 1]` and interpolating
|
||||
between them — in RGB or HSV space (`--interpolation rgb|hsv`, HSV by
|
||||
default, since it avoids muddy grey midpoints between distant hues).
|
||||
|
||||
The gradient is always **cyclic**: the last stop blends back into the
|
||||
first. This is what makes `scroll` and `pingpong` animation seamless.
|
||||
|
||||
Preview exactly what a scene's gradient looks like with:
|
||||
|
||||
```bash
|
||||
openrgb-hue scenes show "Blossom" --interpolation rgb --no-dedupe
|
||||
```
|
||||
|
||||
### Mapping modes
|
||||
|
||||
Mapping decides *where on the gradient* each physical LED sits.
|
||||
|
||||
| Mode | Description |
|
||||
| --- | --- |
|
||||
| `sequence` (default) | All targeted LEDs, in order, span the gradient once. |
|
||||
| `per-device` | Every device shows the full gradient independently (e.g. each LED strip gets its own rainbow). |
|
||||
| `per-zone` | Like `per-device`, but per zone — useful for multi-zone devices (e.g. a case with front/top fans). |
|
||||
| `matrix` | 2D directional mapping for grid devices (keyboards, LED matrices), using `--direction`. |
|
||||
| `mirror` | The gradient plays forward then backward across the target set (a spatial ping-pong layout). |
|
||||
| `shuffle` | The gradient's colors are assigned to LEDs in a randomized (but seed-deterministic) order. |
|
||||
|
||||
`matrix` mode supports four `--direction` values:
|
||||
|
||||
- `left-right` — gradient flows across columns.
|
||||
- `top-bottom` — gradient flows across rows.
|
||||
- `diagonal` — gradient flows from the top-left corner to the bottom-right.
|
||||
- `radial` — gradient flows outward from the grid's center.
|
||||
|
||||
```bash
|
||||
openrgb-hue apply "Savanna sunset" --mapping per-device
|
||||
openrgb-hue apply "Savanna sunset" --device-type keyboard --mapping matrix --direction radial
|
||||
```
|
||||
|
||||
### Animation modes
|
||||
|
||||
Animation decides *how the mapped gradient changes over time*. Any
|
||||
animation mode works with any mapping mode: mapping only ever produces a
|
||||
static per-LED gradient position, and animation only ever shifts that
|
||||
position and/or brightness over time.
|
||||
|
||||
| Mode | Description | Key flags |
|
||||
| --- | --- | --- |
|
||||
| `static` | No motion (used internally by `apply`; useful with `animate --duration` to just hold a look). | — |
|
||||
| `scroll` (default for `animate`) | The gradient continuously translates, wrapping seamlessly. | `--speed` (cycles/sec) |
|
||||
| `pingpong` | Like `scroll`, but reverses direction at the ends instead of wrapping. | `--speed` |
|
||||
| `pulse` | The whole gradient's brightness breathes sinusoidally. | `--speed` (Hz), `--min-brightness` |
|
||||
| `wave` | A brightness sine wave travels spatially across the LEDs. | `--speed`, `--min-brightness`, `--wavelength` |
|
||||
|
||||
```bash
|
||||
openrgb-hue animate "Tropical twilight" --mode wave --speed 0.3 --wavelength 3 --fps 60
|
||||
openrgb-hue animate "Relax" --mode pulse --min-brightness 0.1 --duration 30
|
||||
```
|
||||
|
||||
## Full command reference
|
||||
|
||||
```
|
||||
openrgb-hue [--host HOST] [--port PORT] [--version] COMMAND [ARGS]...
|
||||
|
||||
devices [--leds] List connected OpenRGB devices (and optionally zones/LEDs).
|
||||
|
||||
scenes list [--filter TEXT] List bundled Hue scenes with a gradient preview.
|
||||
scenes show NAME [...] Show a scene's raw light data and derived gradient.
|
||||
scenes update [--url] [--timeout] Refresh the bundled scene dataset from the source gist.
|
||||
|
||||
apply SCENE [OPTIONS] Apply a scene as a static gradient.
|
||||
animate SCENE [OPTIONS] Apply a scene as an animated gradient.
|
||||
|
||||
off [--device] [--device-type] [--zone] Turn off targeted (or all) LEDs.
|
||||
clear Turn off every LED on every device.
|
||||
```
|
||||
|
||||
Run `openrgb-hue COMMAND --help` for the full, up-to-date flag list of any
|
||||
command — every flag mentioned in this README (mapping/animation options,
|
||||
targeting filters, dry-run flags, etc.) is documented there too.
|
||||
|
||||
## Targeting devices and zones
|
||||
|
||||
`apply`, `animate`, and `off` all accept repeatable filters:
|
||||
|
||||
- `--device NAME` — match devices with this exact name (case-insensitive).
|
||||
- `--device-type TYPE` — match devices of this type, e.g. `gpu`,
|
||||
`motherboard`, `ledstrip`, `keyboard` (case-insensitive).
|
||||
- `--zone NAME` — match zones with this exact name (case-insensitive).
|
||||
|
||||
Filters within the same category are OR'd together; different categories
|
||||
are AND'd. No filters means "every LED on every device". Run
|
||||
`openrgb-hue devices --leds` to see exact device, zone, and LED names.
|
||||
|
||||
```bash
|
||||
openrgb-hue apply "Bright" --device-type gpu --device-type motherboard
|
||||
openrgb-hue apply "Bright" --zone "Front Fans"
|
||||
```
|
||||
|
||||
## Dry-run / preview without hardware
|
||||
|
||||
Both `apply` and `animate` accept `--dry-run`, which runs the full
|
||||
targets → mapping → gradient (→ animation) pipeline against a small
|
||||
synthetic LED layout and prints the result as terminal swatches — no
|
||||
OpenRGB connection needed. Useful for previewing a look before touching
|
||||
real hardware, or for trying out mapping/animation combinations when
|
||||
OpenRGB isn't running.
|
||||
|
||||
```bash
|
||||
openrgb-hue apply "Blossom" --dry-run --mapping matrix --direction diagonal
|
||||
openrgb-hue animate "Blossom" --dry-run --mode wave --dry-run-frames 8
|
||||
```
|
||||
|
||||
`--dry-run-leds N` synthesizes N flat LEDs instead of the default mixed
|
||||
layout (two linear strips plus a 4x4 matrix zone). `--dry-run-frames N`
|
||||
(on `animate`) controls how many evenly-spaced sample frames are printed.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**"Could not connect to the OpenRGB SDK server"** — make sure the OpenRGB
|
||||
app is running and its SDK Server has been started (Settings → SDK Server
|
||||
→ Start Server). If it's running on a different host/port, pass
|
||||
`--host`/`--port` (or set the `OPENRGB_HOST`/`OPENRGB_PORT` environment
|
||||
variables).
|
||||
|
||||
## Data attribution
|
||||
|
||||
The bundled scene data (`src/openrgb_hue/data/philips-hue-scenes.csv`) is
|
||||
sourced from a public gist compiled by GitHub user
|
||||
[labmonkey](https://gist.github.com/labmonkey/a641f6b339ed9a71bdced64b9af91ee8),
|
||||
capturing the default Philips Hue app scenes and their per-light colors via
|
||||
the Home Assistant API. No explicit license is stated by the source gist;
|
||||
it's used here, with attribution, as a factual color dataset. Run
|
||||
`openrgb-hue scenes update` to refresh from the source at any time. See
|
||||
`src/openrgb_hue/data/ATTRIBUTION.txt` for the full note.
|
||||
|
||||
## License
|
||||
|
||||
The code in this repository is licensed under the [MIT License](LICENSE).
|
||||
The bundled scene dataset's licensing follows the source gist (see above).
|
||||
|
||||
## Contributing
|
||||
|
||||
Issues and pull requests are welcome. Run the test suite with:
|
||||
|
||||
```bash
|
||||
pip install -e ".[dev]"
|
||||
pytest
|
||||
```
|
||||
+13
@@ -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.
|
||||
@@ -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"]
|
||||
@@ -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__"]
|
||||
@@ -0,0 +1,4 @@
|
||||
from openrgb_hue.cli import app
|
||||
|
||||
if __name__ == "__main__":
|
||||
app()
|
||||
@@ -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)
|
||||
@@ -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=[])
|
||||
@@ -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)
|
||||
@@ -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)
|
||||
@@ -0,0 +1,11 @@
|
||||
philips-hue-scenes.csv is bundled from a public gist compiled by GitHub user
|
||||
"labmonkey":
|
||||
|
||||
https://gist.github.com/labmonkey/a641f6b339ed9a71bdced64b9af91ee8
|
||||
|
||||
It is a dump of the default Philips Hue scenes and their per-light RGB and
|
||||
brightness values, captured via the Home Assistant API. No explicit license
|
||||
is stated by the source gist; the data is used here as a factual color
|
||||
dataset, with attribution, for the purpose of deriving gradient color
|
||||
palettes. Run `openrgb-hue scenes update` to re-download the latest copy
|
||||
from the source gist.
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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)]
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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]
|
||||
@@ -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)
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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) == []
|
||||
@@ -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())
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user