- event bus systen - all components are independent - preparation for web frontend
129 lines
4.0 KiB
Python
129 lines
4.0 KiB
Python
"""Colour types shared by the LED wire format and the config schema.
|
|
|
|
Kept separate from :mod:`musicmouse.devices.effects` so that :mod:`musicmouse.config`
|
|
can validate colours without importing anything device-related.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import colorsys
|
|
import struct
|
|
from dataclasses import dataclass
|
|
|
|
__all__ = ["ColorHSV", "ColorRGBW", "parse_color"]
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ColorRGBW:
|
|
"""An RGBW colour with all channels normalised to ``0.0 .. 1.0``."""
|
|
|
|
r: float
|
|
g: float
|
|
b: float
|
|
w: float
|
|
|
|
def __repr__(self) -> str:
|
|
return f"#({self.r}, {self.g}, {self.b}, {self.w})"
|
|
|
|
@property
|
|
def is_valid(self) -> bool:
|
|
return all(0 <= v <= 1 for v in (self.r, self.g, self.b, self.w))
|
|
|
|
def as_bytes(self) -> bytes:
|
|
if not self.is_valid:
|
|
raise ValueError(f"Channel values must be within 0..1, got {self!r}")
|
|
return struct.pack(
|
|
"<BBBB",
|
|
int(self.r * 255),
|
|
int(self.g * 255),
|
|
int(self.b * 255),
|
|
int(self.w * 255),
|
|
)
|
|
|
|
@classmethod
|
|
def from_bytes(cls, data: bytes) -> ColorRGBW:
|
|
r, g, b, w = struct.unpack("<BBBB", data)
|
|
return cls(r / 255, g / 255, b / 255, w / 255)
|
|
|
|
def __mul__(self, scale: float) -> ColorRGBW:
|
|
if not 0 <= scale <= 1:
|
|
raise ValueError(f"Scale must be within 0..1, got {scale}")
|
|
return ColorRGBW(self.r * scale, self.g * scale, self.b * scale, self.w * scale)
|
|
|
|
def without_white_channel(self) -> ColorRGBW:
|
|
"""Fold the white channel into RGB, for strips driven without a W channel."""
|
|
r, g, b = (min(1.0, c + self.w) for c in (self.r, self.g, self.b))
|
|
return ColorRGBW(r, g, b, 0)
|
|
|
|
|
|
@dataclass(frozen=True, slots=True)
|
|
class ColorHSV:
|
|
"""Hue in degrees (``0..360``), saturation ``0..1``, value ``0..2``."""
|
|
|
|
h: float
|
|
s: float
|
|
v: float
|
|
|
|
def __repr__(self) -> str:
|
|
return f"ColorHSV({self.h}, {self.s}, {self.v})"
|
|
|
|
@staticmethod
|
|
def from_rgb(rgb: ColorRGBW) -> ColorHSV:
|
|
h, s, v = colorsys.rgb_to_hsv(rgb.r, rgb.g, rgb.b)
|
|
return ColorHSV(h * 360, s, v)
|
|
|
|
@property
|
|
def is_valid(self) -> bool:
|
|
return 0 <= self.h <= 360 and 0 <= self.s <= 1 and 0 <= self.v <= 2
|
|
|
|
def as_bytes(self) -> bytes:
|
|
if not self.is_valid:
|
|
raise ValueError(f"Out-of-range HSV colour {self!r}")
|
|
return struct.pack("<fff", self.h, self.s, self.v)
|
|
|
|
@classmethod
|
|
def from_bytes(cls, data: bytes) -> ColorHSV:
|
|
return cls(*struct.unpack("<fff", data))
|
|
|
|
|
|
def parse_color(value: str | ColorRGBW) -> ColorRGBW:
|
|
"""Parse ``"#rrggbb"`` (RGB) or ``"wNN"`` (white channel only) into a colour.
|
|
|
|
Raises:
|
|
ValueError: with a message naming the accepted formats.
|
|
"""
|
|
if isinstance(value, ColorRGBW):
|
|
return value
|
|
if not isinstance(value, str):
|
|
raise ValueError(f"expected a colour string, got {type(value).__name__}")
|
|
|
|
text = value.strip()
|
|
if text.startswith("#"):
|
|
digits = text[1:]
|
|
if len(digits) != 6:
|
|
raise ValueError(
|
|
f"unrecognized color format {value!r} "
|
|
f"(expected '#rrggbb' with 6 hex digits, got {len(digits)})"
|
|
)
|
|
try:
|
|
r, g, b = (int(digits[i : i + 2], 16) / 255 for i in (0, 2, 4))
|
|
except ValueError:
|
|
raise ValueError(
|
|
f"unrecognized color format {value!r} (expected '#rrggbb' with hex digits)"
|
|
) from None
|
|
return ColorRGBW(r, g, b, 0)
|
|
|
|
if text.startswith("w"):
|
|
digits = text[1:]
|
|
try:
|
|
white = int(digits, 16)
|
|
except ValueError:
|
|
raise ValueError(
|
|
f"unrecognized color format {value!r} (expected 'wNN' with hex digits)"
|
|
) from None
|
|
if not 0 <= white <= 255:
|
|
raise ValueError(f"white value in {value!r} must be within 00..ff")
|
|
return ColorRGBW(0, 0, 0, white / 255)
|
|
|
|
raise ValueError(f"unrecognized color format {value!r} (expected '#rrggbb' or 'wNN')")
|