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