Files
musicmouse/python-backend/musicmouse/color.py
Martin Bauer d44c24ec97 Full rearchitecture using Claude
- event bus systen
- all components are independent
- preparation for web frontend
2026-08-26 13:22:28 +02:00

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')")