"""Manages the RGBCW color.""" import logging from math import atan2, cos, pi from typing import Iterable, Tuple from .vec import ( EPSILON, Vector, vecAdd, vecDot, vecFormat, vecFromAngle, vecInt, vecLen, vecMul, ) _LOGGER = logging.getLogger(__name__) ANGLE = (pi * 2) / 3 BASIS = ( vecFromAngle(0), vecFromAngle(ANGLE), vecFromAngle(ANGLE * 2), ) # the max value we will use for c and w CWMAX = 128 BUFFER = "" def debug(msg: str, end: str = "\n") -> None: global BUFFER BUFFER += msg if end == "\n": _LOGGER.debug(BUFFER) BUFFER = "" else: BUFFER += end def printBasis(basis: Iterable[Vector], prefix: str = "") -> None: debug(f"{prefix}Basis Vectors: ", end="") for vector in basis: debug(f"{vecFormat(vector)} ", end="") debug("") Trapezoid = Tuple[Vector, int] def trapezoid(hueVec: Vector, saturation: float) -> Trapezoid: """This function computes the linear combination of two basis vectors that define a trapezoid. hueVec - a normalized vector in the hue color wheel (0..1, 0..1, 0..1) saturation - a single value representing the length of the hue vector (0..1) brightness - a separate value that may be passed in, and should be used in the Pilot. """ rgb: Tuple[float, ...] # if saturation is essentially 0, just go to the full on if saturation <= EPSILON: rgb = (0, 0, 0) else: # we want to compute the actual RGB color of the saturated point as a linear # combination of no more than two of the basis vectors. first we have to figure # out which of the basis vectors we will use maxAngle = cos((pi * 2 / 3) - EPSILON) mask = tuple( (1 if (vecDot(hueVec, vector) > maxAngle) else 0) for vector in BASIS ) count = sum(mask) debug( f" Max Angle: {maxAngle:0.3f}, Mask: ({mask[0]}, {mask[1]}, {mask[2]}), Count: {count}" ) if count == 1: # easy case, it's just one color component rgb = mask else: # recast as a ray-line intersection using the two found basis vectors, note # the basis vectors are normalized by definition subBasis = [BASIS[i] for i, maskVal in enumerate(mask) if (maskVal == 1)] printBasis(subBasis, " ") # define the line from the origin along the second vector, computing its # equation in the form Ax + C = 0, but C is always 0 for this line AB = (subBasis[1][1], subBasis[1][0] * -1) # intersect the ray from the saturation point along the first basis vector # with the line we just computed, these are definitely not co-linear, so there # should always be an intersection point, and the result should always be in # the range [-1 .. 1], this is the first basis coefficient coeff0 = vecDot(hueVec, AB) / vecDot(subBasis[0], AB) # compute the intersection point, and the second basis coefficient, note that # we compute the coefficients to always be positive, but the intersection calculation # needs to be in the opposite direction from the basis vector (hence the negative on # coeff0). intersection = vecAdd(vecMul(subBasis[0], -coeff0), hueVec) coeff = ( coeff0, vecDot(intersection, subBasis[1]), ) debug( f" Intersection Point: {vecFormat(intersection)}, Coefficients: {vecFormat(coeff)}" ) # there's a bit of a gamut problem here, as the area outside the hexagon defined by # the three unit basis vectors is not actually reachable. this manifests as # coefficients greater than 1, which will always happen unless the target color is # either one of the basis vectors or a bisector of two basis vectors. we scale both # coefficients by 1/maxCoefficient to make valid colors maxCoeff = max(coeff) coeff = (coeff[0] / maxCoeff, coeff[1] / maxCoeff) debug(f" Scaled Coefficients: {vecFormat(coeff)}") # now rebuild the rgb vector by putting the coefficients into the correct place j = 0 rgbList = [] for i in range(3): if mask[i] == 1: rgbList.append(min(coeff[j], 1)) j += 1 else: rgbList.append(0) rgb = tuple(rgbList) # we want a discontinuous behavior. if saturation >= 0.5, we want the color to remain saturated # and we scale the cw value down to 0 as saturation goes from 0.5 to 1. if saturation < 0.5, we # want to saturate cw, and scale the rgb down to (0, 0, 0) as saturation goes from 0.5 - 0 if saturation >= 0.5: # rgb remains saturated # scale the cw value down to 0 as saturation goes from 0.5 to 1 cw = 1 - ((saturation - 0.5) * 2) else: cw = 1 rgb = vecMul(rgb, saturation * 2) # scale back to the pilot color space rgb = vecInt(vecMul(rgb, 255)) out_cw = int(max(0, cw * CWMAX)) debug(f" RGB OUT: {rgb}, CW: {out_cw}") # the wiz light appears to have 5 different LEDs, r, g, b, warm_white, and cold_white # there appears to be a max power supplied across the 5 LEDs, which explains why all- # on full isn't the brightest configuration # warm_white appears to be 2800k, and cold_white appears to be 6200k, somewhat neutral # brightness is achieved by turning both of them on return rgb, out_cw def rgb2rgbcw(rgb: Vector) -> Trapezoid: """Convert rgb to rgbcw. Given a rgb tuple in the range (0..255, 0..255, 0-255), convert that to a rgbcw for the wiz light. brightness may or may not be passed in and is passed through to the trapezoid function. """ debug(f"RGB IN: {rgb}") # scale the vector into canonical space ([0-1]) rgb = vecMul(rgb, 1 / 255) # compute the hue vector as a linear combination of the basis vectors, and extract the # saturation, there's probably a better pythonese way of doing this hueVec = vecAdd( vecAdd( vecMul(BASIS[0], rgb[0]), vecMul(BASIS[1], rgb[1]), ), vecMul(BASIS[2], rgb[2]), ) saturation = vecLen(hueVec) if saturation > EPSILON: hueVec = vecMul(hueVec, 1 / saturation) return trapezoid(hueVec, saturation) def rgbcw2hs(rgb: Vector, cw: float) -> Tuple[float, float]: """Convert rgb hue. Given a tuple that is r,g,b and cw in 0-255 range, convert that to a hue, saturation tuple in the range (0..360, 0..100). """ # scale the rgb and cw values into canonical space (the wiz app might set cw to higher than the # value we use, so we have to allow for that rgb = vecMul(rgb, 1 / 255) cw = min(cw, CWMAX) / CWMAX # compute the hue vector as a linear combination of the basis vectors, there's probably a # better pythonese way of doing this hueVec = vecAdd( vecAdd( vecMul(BASIS[0], rgb[0]), vecMul(BASIS[1], rgb[1]), ), vecMul(BASIS[2], rgb[2]), ) debug(f"RGB IN: {vecFormat(rgb)}, CW: {cw:.3f}, HUE VECTOR: {vecFormat(hueVec)}") # the discontinuous nature of the wiz bulb setting means we have two different states: # 1) the cw value is 1, and the hue vector is scaled (from 50% saturation to white) # 2) the hue vector is saturated, and cw is scaled down (from 50% saturation to full color) if cw == 1: # hue scales down to (0, 0) at saturation 0, up to unit length at 50% saturation, so we get # that length, normalize the vector, and scale the saturation to reflect the half range hueVecLength = vecLen(hueVec) if hueVecLength > EPSILON: vecMul(hueVec, 1 / hueVecLength) saturation = hueVecLength * 0.5 else: # the hue vector is already fully saturated, and cw scales from 0 - 0.5 to add in white light saturation = 1 - (cw / 2) # we have a saturated version of the hue vector now, which we convert to a hue vector and # then extract the angle of the vector in radians. We add 2 Pi to the angle if it is less than # 0 to put the hue angle in the range from 0 to 2 Pi hue = atan2(hueVec[1], hueVec[0]) while hue < 0: hue += pi * 2 # scale the hue/saturation values back to their native ranges and return the tuple hue *= 180 / pi saturation *= 100 debug(f" HUE OUT: {hue:.5f}, SATURATION: {saturation:.3f}") return hue, saturation def hs2rgbcw(hs: Tuple[float, float]) -> Trapezoid: """Convert hue to a canonical value. given a hue, saturation tuple in the range (0..360, 0..100), convert that to a rgbcw for the wiz light brightness may or may not be passed in and is passed through to the trapezoid function. """ hueCanonical = hs[0] / 360 while hueCanonical >= 1: hueCanonical -= 1 # compute hue in a discretized space and convert to radians, then a vector hueRadians = hueCanonical * pi * 2 hueVec = vecFromAngle(hueRadians) # convert saturation to a canonical value in a discretized space # we take the square root to give the user more visual control saturation = hs[1] / 100 debug( f"HS IN: {vecFormat(hs)}, HUE: {hueRadians:.5f}, SATURATION: {saturation:.3f}" ) return trapezoid(hueVec, saturation) def convertHSfromRGBCW(rgb: Tuple[float, ...], cw: int) -> Tuple[float, float]: """Convert rgb hue. Given a tuple that is r,g,b and cw in 0-255 range, convert that to a hue, saturation tuple in the range (0..360, 0..100). """ red, green, blue = rgb if 0 <= red < 256 and 0 <= green < 256 and 0 <= blue < 256 and 0 <= cw < 255: return rgbcw2hs(rgb, cw) else: raise ValueError("Invalid RGB or CW values.")