diff --git a/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/BaseLightThingHandler.java b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/BaseLightThingHandler.java new file mode 100644 index 000000000..45a2bb1b5 --- /dev/null +++ b/bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/BaseLightThingHandler.java @@ -0,0 +1,228 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.thing.binding; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.openhab.core.thing.ChannelUID; +import org.openhab.core.thing.Thing; +import org.openhab.core.types.Command; +import org.openhab.core.util.LightModel; + +/** + * {@link BaseLightThingHandler} provides an abstract base implementation for a {@link ThingHandler} for a light. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public abstract class BaseLightThingHandler extends BaseThingHandler { + + /** + * Light state machine model to manage light capabilities, configuration, and runtime state + */ + protected final LightModel model = new LightModel(); + + public BaseLightThingHandler(Thing thing) { + super(thing); + } + + /** + * Override this method to handle commands from OH core. + *
+ * Example: (implementation will depend on the specific binding and device). + * + *
+ * {@code
+ *
+ * // update the model state based on the command from OpenHAB
+ * model.handleCommand(command);
+ *
+ * // or if it is a color temperature command
+ * model.handleColorTemperatureCommand(command);
+ *
+ * // and transmit the appropriate command to the remote light device based on the model state
+ * doTransmitBindingSpecificRemoteLightData(model);
+ *
+ * }
+ *
+ */
+ @Override
+ public abstract void handleCommand(ChannelUID channelUID, Command command);
+
+ /**
+ * Override this method to provide initialization of the light state machine capabilities and configuration
+ * parameters.
+ * + * Example: (implementation will depend on the specific binding and device). + * + *
+ * {@code
+ *
+ * // STEP 1: Set up the light state machine capabilities.
+ * model.configSetLightCapabilities(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE);
+ *
+ * // STEP 2: optionally set up the light state machine configuration parameters.
+ * // These would typically be read from the thing configuration or read from the remote device.
+ * model.configSetRgbDataType(RgbDataType.RGB_NO_BRIGHTNESS); // RGB data type
+ * model.configSetMinimumOnBrightness(2); // minimum brightness level in % when on
+ * model.configSetIncreaseDecreaseStep(10); // step size for increase/decrease commands
+ * model.configSetMirekControlCoolest(153); // color temperature control range coolest
+ * model.configSetMirekControlWarmest(500); // color temperature control range warmest
+ *
+ * // STEP 3: optionally if the light has warm and cool white LEDS then set up their LED color temperatures.
+ * // These would typically be read from the thing configuration or read from the remote device.
+ * model.configSetMirekCoolWhiteLED(153);
+ * model.configSetMirekWarmWhiteLED(500);
+ *
+ * // STEP 4: now set the status to UNKNOWN to indicate that we are initialized
+ * updateStatus(ThingStatus.UNKNOWN);
+ *
+ * // STEP 5: finally provide further initialization, e.g. connecting to the remote device
+ * ...
+ *
+ * }
+ *
+ */
+ @Override
+ public abstract void initialize();
+
+ /**
+ * Transmit the appropriate command to the remote light device based on the model state.
+ * This method must be overridden in the concrete implementation to transmit the appropriate command(s)
+ * to the remote light device based on the model state.
+ * + * Example: (implementation will depend on the specific binding and device). + * + *
+ * {@code
+ *
+ * if (model.getOnOff() == OnOffType.ON) {
+ * transmit command to turn on the light
+ * } else {
+ * transmit command to turn off the light
+ * }
+ *
+ * if (model.getBrightness() != null) {
+ * transmit command to set brightness to model.getBrightness()
+ * }
+ *
+ * if (model.getColor() != null) {
+ * transmit command to set color to model.getColor()
+ * }
+ *
+ * if (model.getColorTemperature() != null) {
+ * transmit command to set color temperature to model.getColorTemperature()
+ * }
+ *
+ * if (model.getColorTemperaturePercent() != null) {
+ * transmit command to set color temperature percent to model.getColorTemperaturePercent()
+ * }
+ *
+ * if (model.getRGBx().length == 3) {
+ * transmit command to set RGB value to model.getRGBx()
+ * }
+ *
+ * if the light supports XY color coordinates:
+ * transmit command to set XY value to model.getXY()
+ * }
+ * }
+ *
+ *
+ * @param model the light model containing the current state
+ */
+ protected abstract void doTransmitBindingSpecificRemoteLightData(LightModel model);
+
+ /**
+ * Receive data from the remote light device and update the model state accordingly.
+ * This method must be overridden in the concrete implementation to 1) receive data from the remote light device
+ * 2) update the model state accordingly, and 3) update the openHAB channels.
+ * + * Example: (implementation will depend on the specific binding and device). + * + *
+ * {@code
+ *
+ * STEP 1: Parse the remoteData to extract the relevant information. Depends on specific binding / device
+ *
+ * OnOffType onOff = ...; // extract on/off state from remoteData
+ * Integer brightness = ...; // extract brightness from remoteData
+ * HSBType color = ...; // extract color from remoteData
+ * Integer colorTemperature = ...; // extract color temperature from remoteData
+ * Integer colorTemperaturePercent = ...; // extract color temperature percent from remoteData
+ * RGBType rgb = ...; // extract RGB value from remoteData
+ * XYType xy = ...; // extract XY value from remoteData
+ *
+ * STEP 2: Update the model state based on the received data
+ *
+ * if (onOff != null) {
+ * model.setOnOff(onOff.booleanValue());
+ * }
+ *
+ * if (brightness != null) {
+ * model.setBrightness(brightness);
+ * }
+ *
+ * if (color != null) {
+ * model.setColor(color);
+ * }
+ *
+ * if (rgb != null) {
+ * model.setRGBx(rgb);
+ * }
+ *
+ * if (xy != null) {
+ * model.setXY(xy);
+ * }
+ *
+ * Handle color temperature reports from the device
+ * Option A: device reports color temperature directly in Mirek (micro reciprocal Kelvin)
+ * if (colorTemperatureMirek != null) {
+ * model.setMirek(colorTemperatureMirek);
+ * }
+ *
+ * Option B: device reports color temperature in Kelvin
+ * if (colorTemperatureKelvin != null) {
+ * // Convert Kelvin to Mirek: mirek = 1_000_000 / kelvin
+ * double mirek = 1_000_000d / colorTemperatureKelvin;
+ * model.setMirek(mirek);
+ * }
+ *
+ * Option C: device reports color temperature as a 0–100% value
+ * if (colorTemperaturePercent != null) {
+ * // Map the percent value to your device's supported Mirek range (mirekMin..mirekMax)
+ * // before updating the model. Example:
+ * double mirek = mirekMin + (mirekMax - mirekMin) * colorTemperaturePercent / 100.0;
+ * model.setMirek(mirek);
+ * }
+ *
+ * STEP 3: After updating the model, update the channel states in OpenHAB
+ * Note: Ensure that the channel IDs used in updateState() match those defined in the thing type.
+ *
+ * if (model.configGetLightCapabilities().supportsColor()) {
+ * updateState(CHANNEL_COLOR, model.getColor());
+ * } else if (model.configGetLightCapabilities().supportsBrightness()) {
+ * updateState(CHANNEL_BRIGHTNESS, model.getBrightness());
+ * } else {
+ * updateState(CHANNEL_ON_OFF, model.getOnOff());
+ * }
+ *
+ * if (model.configGetLightCapabilities().supportsColorTemperature()) {
+ * updateState(CHANNEL_COLOR_TEMPERATURE_ABS, model.getColorTemperature());
+ * updateState(CHANNEL_COLOR_TEMPERATURE_PERCENT, model.getColorTemperaturePercent());
+ * }
+ * }
+ *
+ *
+ * @param remoteData the data received from the remote light device
+ */
+ protected abstract void onReceiveBindingSpecificRemoteLightData(Object... remoteData);
+}
diff --git a/bundles/org.openhab.core/src/main/java/org/openhab/core/util/LightModel.java b/bundles/org.openhab.core/src/main/java/org/openhab/core/util/LightModel.java
new file mode 100644
index 000000000..5fde69479
--- /dev/null
+++ b/bundles/org.openhab.core/src/main/java/org/openhab/core/util/LightModel.java
@@ -0,0 +1,1536 @@
+/*
+ * Copyright (c) 2010-2026 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.core.util;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.Objects;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.library.types.DecimalType;
+import org.openhab.core.library.types.HSBType;
+import org.openhab.core.library.types.IncreaseDecreaseType;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.PercentType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.State;
+import org.openhab.core.types.UnDefType;
+
+/**
+ * The {@link LightModel} provides a state machine model for maintaining and modifying the state of a light,
+ * which is intended to be used within the Thing Handler of a lighting binding.
+ * + * + * It supports lights with different capabilities, including: + *
+ * See also {@link ColorUtil} for other color conversions. + *
+ * To use the model you must initialize the {@link #lightCapabilities} during initialization as follows: + *
+ * The model specifically handles the following "exotic" cases: + *
+ * A typical use case is within in a ThingHandler as follows: + * + *
+ * {@code
+ * public class LightModelHandler extends BaseThingHandler {
+ *
+ * // initialize the light model with default capabilities and parameters
+ * private final LightModel model = new LightModel();
+ *
+ * @Override
+ * public void initialize() {
+ * // Set up the light state machine capabilities.
+ * model.configSetLightCapabilities(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE);
+ *
+ * // Optionally: set up the light state machine configuration parameters.
+ * // These would typically be read from the thing configuration or read from the remote device.
+ * model.configSetRgbDataType(RgbDataType.RGB_NO_BRIGHTNESS); // RGB data type
+ * model.configSetMinimumOnBrightness(2); // minimum brightness level when on 2%
+ * model.configSetIncreaseDecreaseStep(10); // step size for increase/decrease commands
+ * model.configSetMirekControlCoolest(153); // color temperature control range
+ * model.configSetMirekControlWarmest(500); // color temperature control range
+ *
+ * // Optionally: if the light has warm and cool white LEDS then set up their LED color temperatures.
+ * // These would typically be read from the thing configuration or read from the remote device.
+ * model.configSetMirekCoolWhiteLED(153);
+ * model.configSetMirekWarmWhiteLED(500);
+ *
+ * // now set the status to UNKNOWN to indicate that we are initialized
+ * updateStatus(ThingStatus.UNKNOWN);
+ * }
+ *
+ * @Override
+ * public void handleCommand(ChannelUID channelUID, Command command) {
+ * // update the model state based on a command from OpenHAB
+ * model.handleCommand(command);
+ *
+ * // or if it is a color temperature command
+ * model.handleColorTemperatureCommand(command);
+ *
+ * sendBindingSpecificCommandToUpdateRemoteLight(
+ * .. model.getOnOff() or
+ * .. model.getBrightness() or
+ * .. model.getColor() or
+ * .. model.getColorTemperature() or
+ * .. model.getColorTemperaturePercent() or
+ * .. model.getRGBx() or
+ * .. model.getXY() or
+ * );
+ * }
+ *
+ * // method that sends the updated state data to the remote light
+ * private void sendBindingSpecificCommandToUpdateRemoteLight(..) {
+ * // binding specific code
+ * }
+ *
+ * // method that receives data from remote light, and updates the model, and then OH
+ * private void receiveBindingSpecificDataFromRemoteLight(double... receivedData) {
+ * // update the model state based on the data received from the remote
+ * model.setBrightness(receivedData[0]);
+ * model.setRGBx(receivedData[1], receivedData[2], receivedData[3]);
+ * model.setMirek(receivedData[4]);
+ *
+ * // update the OH channels with the new state values
+ * updateState(onOffChannelUID, model.getOnOff());
+ * updateState(brightnessChannelUID, model.getBrightness());
+ * updateState(colorChannelUID, model.getColor());
+ * updateState(colorTemperatureChannelUID, model.getColorTemperature());
+ * }
+ * }
+ * }
+ *
+ * + * DEVELOPER NOTE: + *
+ * This is a HERMETIC class that represents the complete state of a light. The state consists in the entirety of + * all the fields within a class instance. In other words, if any one field changes, then the entire state changes. + * Therefore all public methods MUST be (and indeed are) synchronized to prevent read/write of "half- ready" states. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class LightModel { + + /* + * ================================================================================ + * SECTION: Common Enumerators for light capabilities. + * ================================================================================ + */ + + /** + * Enum for the capabilities of different types of lights + *
+ * Different brands of light support different capabilities. Some only support on-off, some support + * brightness, some support color temperature, and some support full color. This enum + * defines the different combinations of capabilities that a light may support. + */ + public static enum LightCapabilities { + /** on-off only */ + ON_OFF, + /** on-off with brightness */ + BRIGHTNESS, + /** on-off with brightness and color temperature */ + BRIGHTNESS_WITH_COLOR_TEMPERATURE, + /** on-off with brightness and color */ + COLOR, + /** on-off with brightness, color and color temperature */ + COLOR_WITH_COLOR_TEMPERATURE; + + public boolean supportsBrightness() { + return this != ON_OFF; + } + + public boolean supportsColor() { + return this == COLOR || this == COLOR_WITH_COLOR_TEMPERATURE; + } + + public boolean supportsColorTemperature() { + return this == BRIGHTNESS_WITH_COLOR_TEMPERATURE || this == COLOR_WITH_COLOR_TEMPERATURE; + } + } + + /** + * Enum for the different types of RGB data + *
+ * Different brands of light use different types of RGB data. Some only support plain RGB, some support RGB + * with a single white channel, and some support RGB with both cold and warm white channels. Also some lights + * use their RGBx values to represent only the hue and saturation (only the HS parts), and they have another + * separate control channel for the brightness (B part). Whereby others use the RGBx values to represent the + * hue, saturation and brightness all together (all the HSB parts). + */ + public static enum RgbDataType { + /** supports plain RGB with brightness (i.e. full HSBType) */ + DEFAULT, + /** supports plain RGB but ignores brightness (i.e. only HS parts of HSBType) */ + RGB_NO_BRIGHTNESS, + /** supports 4-element RGB with white channel */ + RGB_W, + /** supports 5-element RGB with cold and warm white channels */ + RGB_C_W + } + + /** + * Enum for the LED operating mode + *
+ * Some brands of light are not able to use the RGB leds and the white led(s) at the same time. So they must + * be switched between WHITE_ONLY and RGB_ONLY mode. Whereas others lights can use any combination of RGB and + * White leds at the same time they must be switched COMBINED mode. If the mode is changed at runtime then the + * color and/or color temperature are updated to be consistent with the new mode, while keeping the brightness + * the same. If the light does not support color then the mode is forced to WHITE_ONLY. + */ + public static enum LedOperatingMode { + /** operating with RGB LEDs only */ + RGB_ONLY, + /** operating with RGB and white LEDs together */ + COMBINED, + /** operating with white LED(s) only */ + WHITE_ONLY + } + + /* + * ================================================================================ + * SECTION: Default Parameters. May be modified during initialization. + * ================================================================================ + */ + + /** + * Minimum brightness percent to consider as light "ON" + */ + private double minimumOnBrightness = 1.0; + + /** + * The 'coolest' white color temperature in Mirek/Mired + */ + private double mirekControlCoolest = 153; + + /** + * The 'warmest' white color temperature in Mirek/Mired + */ + private double mirekControlWarmest = 500; + + /** + * Step size for IncreaseDecreaseType commands + */ + private double stepSize = 10.0; // step size for IncreaseDecreaseType commands + + /* + * ================================================================================ + * SECTION: Capabilities. May be modified during initialization. + * ================================================================================ + */ + + /** + * The capabilities supported by the light + */ + private LightCapabilities lightCapabilities = LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE; + + /** + * The RGB data type supported + */ + private RgbDataType rgbDataType = RgbDataType.DEFAULT; + + /** + * The capabilities of the cool white LED + */ + private WhiteLED coolWhiteLed = new WhiteLED(mirekControlCoolest); + + /** + * The capabilities of warm white LED + */ + private WhiteLED warmWhiteLed = new WhiteLED(mirekControlWarmest); + + /* + * ================================================================================ + * SECTION: Light state variables. Used at run time only. + * ================================================================================ + */ + + /** + * Cached Brightness state, never null + */ + private PercentType cachedBrightness = PercentType.ZERO; + + /** + * Cached Color state, never null + */ + private HSBType cachedHSB = new HSBType(); + + /** + * Cached Mirek/Mired state, may be NaN if not (yet) known + */ + private double cachedMirek = Double.NaN; + + /** + * Cached OnOff state, may be null if not (yet) known + */ + private @Nullable OnOffType cachedOnOff = null; + + /** + * The current operating mode of the light, default is WHITE only + */ + private LedOperatingMode ledOperatingMode = LedOperatingMode.WHITE_ONLY; + + /* + * ================================================================================ + * SECTION: Constructors + * ================================================================================ + */ + + /** + * Create a {@link LightModel} with default capabilities and parameters as follows: + *
+ * NOTE: If the light has a single white LED then both the 'configSetMirekCoolWhiteLED()' and the + * 'configSetMirekControlWarmest()' methods MUST be called with the identical color temperature. + * + * @param coolLedMirek the color temperature in Mirek/Mired of the cool white LED. + * @throws IllegalArgumentException if the coolLedMirek parameter is out of range. + */ + public synchronized void configSetMirekCoolWhiteLED(double coolLedMirek) throws IllegalArgumentException { + if (coolLedMirek < 100.0 || coolLedMirek > 1000.0) { + throw new IllegalArgumentException( + "Cool LED Mirek/Mired '%.1f' out of range [100.0..1000.0]".formatted(coolLedMirek)); + } + coolWhiteLed = new WhiteLED(coolLedMirek); + } + + /** + * Configuration: set the color temperature of the warm white LED, and thus set the weightings of its + * individual RGB sub- components. + *
+ * NOTE: If the light has a single white LED then both the 'configSetMirekCoolWhiteLED()' and the + * 'configSetMirekControlWarmest()' methods MUST be called with the identical color temperature. + * + * @param warmLedMirek the color temperature in Mirek/Mired of the warm white LED. + */ + public synchronized void configSetMirekWarmWhiteLED(double warmLedMirek) { + if (warmLedMirek < 100.0 || warmLedMirek > 1000.0) { + throw new IllegalArgumentException( + "Warm LED Mirek/Mired '%.1f' out of range [100.0..1000.0]".formatted(warmLedMirek)); + } + warmWhiteLed = new WhiteLED(warmLedMirek); + } + + /** + * Configuration: set the supported RGB type. + * + * @param rgbType the supported RGB type. + */ + public synchronized void configSetRgbDataType(RgbDataType rgbType) { + this.rgbDataType = rgbType; + switch (rgbType) { + case DEFAULT: + case RGB_NO_BRIGHTNESS: + ledOperatingMode = LedOperatingMode.RGB_ONLY; + default: + } + } + + /* + * ================================================================================ + * SECTION: Runtime State getters, setters, and handlers. Only used at runtime. + * ================================================================================ + */ + + /** + * Runtime State: get the brightness or return null if the capability is not supported. + * + * @return PercentType, or null if not supported. + */ + public synchronized @Nullable PercentType getBrightness() { + return getBrightness(false); + } + + /** + * Runtime State: get the brightness or return null if the capability is not supported. + * + * @param forceChannelVisible if true return a non-null value even when color is supported. + * @return PercentType, or null if not supported. + */ + public synchronized @Nullable PercentType getBrightness(boolean forceChannelVisible) { + return lightCapabilities.supportsBrightness() && (!lightCapabilities.supportsColor() || forceChannelVisible) + ? cachedHSB.getBrightness() + : null; + } + + /** + * Runtime State: get the color or return null if the capability is not supported. + * + * @return HSBType, or null if not supported. + */ + public synchronized @Nullable HSBType getColor() { + return lightCapabilities.supportsColor() ? copyHsb(cachedHSB) : null; + } + + /** + * Runtime State: get the color temperature or return null if the capability is not supported. + * or the Mirek/Mired value is not known. + * + * @return QuantityType in Kelvin representing the color temperature, or null if not supported + * or the Mirek/Mired value is not known. + */ + public synchronized @Nullable QuantityType> getColorTemperature() { + if (lightCapabilities.supportsColorTemperature() && !Double.isNaN(cachedMirek)) { + return Objects.requireNonNull( // Mired always converts to Kelvin + QuantityType.valueOf(cachedMirek, Units.MIRED).toInvertibleUnit(Units.KELVIN)); + } + return null; + } + + /** + * Runtime State: get the color temperature in percent or return null if the capability is not supported + * or the Mirek/Mired value is not known. + * + * @return PercentType in range [0..100] representing [coolest..warmest], or null if not supported + * or the Mirek/Mired value is not known. + */ + public synchronized @Nullable PercentType getColorTemperaturePercent() { + if (lightCapabilities.supportsColorTemperature() && !Double.isNaN(cachedMirek)) { + double percent = 100 * (cachedMirek - mirekControlCoolest) / (mirekControlWarmest - mirekControlCoolest); + return new PercentType(new BigDecimal(Math.min(Math.max(percent, 0.0), 100.0))); + } + return null; + } + + /** + * Runtime State: get the hue in range [0..360]. + * + * @return double representing the hue in range [0..360]. + */ + public synchronized double getHue() { + return cachedHSB.getHue().doubleValue(); + } + + /** + * Runtime State: get the HSBType color. + * + * @return HSBType representing the color. + */ + public synchronized HSBType getHsb() { + return copyHsb(cachedHSB); + } + + /** + * Runtime State: get the color temperature in Mirek/Mired, may be NaN if not known. + * + * @return double representing the color temperature in Mirek/Mired. + */ + public synchronized double getMirek() { + return cachedMirek; + } + + /** + * Runtime State: get the on/off state or null if not supported. + * + * @return OnOffType representing the on/off state or null if not supported. + */ + public synchronized @Nullable OnOffType getOnOff() { + return getOnOff(false); + } + + /** + * Runtime State: get the on/off state or null if not supported. + * + * @param forceChannelVisible if true return a non-null value even if brightness or color are supported. + * @return OnOffType representing the on/off state or null if not supported. + */ + public synchronized @Nullable OnOffType getOnOff(boolean forceChannelVisible) { + return (!lightCapabilities.supportsColor() && !lightCapabilities.supportsBrightness()) || forceChannelVisible + ? OnOffType.from(cachedHSB.getBrightness().doubleValue() >= minimumOnBrightness) + : null; + } + + /** + * Runtime State: get the RGB(C)(W) values as an array of doubles in range [0..255]. Depending on the value of + * {@link #rgbDataType}, the array length is either 3 (RGB), 4 (RGBW), or 5 (RGBCW). The array is in the order [red, + * green, blue, (cold-)(white), (warm-white)]. Depending on the value, the brightness may or may not be used as + * follows: + * + *
+ * {@code State state = xyz.toNonNull(xyz.getColor())} is a common usage. + * + * @param state the input State, which may be null. + * @return the input State if it is not null, otherwise 'UnDefType.UNDEF'. + */ + public static State toNonNull(@Nullable State state) { + return state != null ? state : UnDefType.UNDEF; + } + + /** + * Runtime State: create and return a copy of this LightModel. The copy has the same configuration and + * runtime state as this instance. + * + * @return a copy of this LightModel. + */ + public synchronized LightModel copy() { + OnOffType tempOnOff = cachedOnOff; + LightModel copy = new LightModel(lightCapabilities, rgbDataType, minimumOnBrightness, mirekControlCoolest, + mirekControlWarmest, stepSize, coolWhiteLed.getMirek(), warmWhiteLed.getMirek()); + copy.cachedBrightness = PercentType.valueOf(cachedBrightness.toFullString()); + copy.cachedHSB = HSBType.valueOf(cachedHSB.toFullString()); + copy.cachedMirek = cachedMirek; + copy.cachedOnOff = tempOnOff == null ? null : OnOffType.valueOf(tempOnOff.toFullString()); + copy.ledOperatingMode = ledOperatingMode; + return copy; + } + + /* + * ================================================================================ + * SECTION: Internal private methods. Names have 'z' prefix to indicate private. + * ================================================================================ + */ + + /** + * Internal: handle a write brightness command from OH core. + * + * @param brightness the brightness {@link PercentType} to set. + */ + private void zHandleBrightness(PercentType brightness) { + if (brightness.doubleValue() >= minimumOnBrightness) { + cachedBrightness = brightness; + cachedHSB = new HSBType(cachedHSB.getHue(), cachedHSB.getSaturation(), brightness); + cachedOnOff = OnOffType.ON; + } else { + if (OnOffType.ON == cachedOnOff && cachedHSB.getBrightness().doubleValue() >= minimumOnBrightness) { + cachedBrightness = cachedHSB.getBrightness(); // cache the last 'ON' state brightness + } + cachedHSB = new HSBType(cachedHSB.getHue(), cachedHSB.getSaturation(), PercentType.ZERO); + cachedOnOff = OnOffType.OFF; + } + } + + /** + * Internal: handle a write color temperature command from OH core. + * + * @param warmness the color temperature warmness {@link PercentType} to set. + */ + private void zHandleColorTemperature(PercentType warmness) { + setMirek(mirekControlCoolest + ((mirekControlWarmest - mirekControlCoolest) * warmness.doubleValue() / 100.0)); + } + + /** + * Internal: handle a write color temperature command from OH core. + * + * @param colorTemperature the color temperature {@link QuantityType} to set. + * @throws IllegalArgumentException if the colorTemperature parameter is not convertible to Mired. + */ + private void zHandleColorTemperature(QuantityType> colorTemperature) throws IllegalArgumentException { + QuantityType> mirek = colorTemperature.toInvertibleUnit(Units.MIRED); + if (mirek == null) { + throw new IllegalArgumentException( + "Parameter '%s' not convertible to Mirek/Mired".formatted(colorTemperature.toFullString())); + } + setMirek(mirek.doubleValue()); + } + + /** + * Internal: handle a write color command from OH core. + * + * @param hsb the color {@link HSBType} to set. + */ + private void zHandleHSBType(HSBType hsb) { + cachedHSB = hsb; + zHandleBrightness(hsb.getBrightness()); + cachedMirek = zMirekFrom(hsb); + } + + /** + * Internal: handle a write increase/decrease command from OH core, ensuring it is in the range [0.0..100.0] + * + * @param increaseDecrease the {@link IncreaseDecreaseType} command. + */ + private void zHandleIncreaseDecrease(IncreaseDecreaseType increaseDecrease) { + double bri = Math.min(Math.max(cachedHSB.getBrightness().doubleValue() + + ((IncreaseDecreaseType.INCREASE == increaseDecrease ? 1 : -1) * stepSize), 0.0), 100.0); + setBrightness(bri); + } + + /** + * Internal: handle a write on/off command from OH core. + * + * @param onOff the {@link OnOffType} command. + */ + private void zHandleOnOff(OnOffType onOff) { + if (!Objects.equals(onOff, getOnOff())) { + zHandleBrightness(OnOffType.OFF == onOff ? PercentType.ZERO : cachedBrightness); + } + } + + /** + * Internal: return the Mirek/Mired value from the given {@link HSBType} color. The Mirek/Mired value is constrained + * to be within the warmest and coolest limits. + * + * @param hsb the {@link HSBType} color to use to determine the Mirek/Mired value. + */ + private double zMirekFrom(HSBType hsb) { + double[] xyY = ColorUtil.hsbToXY(new HSBType(hsb.getHue(), hsb.getSaturation(), PercentType.HUNDRED)); + double mirek = 1000000 / ColorUtil.xyToKelvin(new double[] { xyY[0], xyY[1] }); + return Math.min(Math.max(mirek, mirekControlCoolest), mirekControlWarmest); + } + + /** + * Internal: create a {@link PercentType} from a double value, ensuring it is in the range [0.0..100.0] + * + * @param value the input value. + * @return a {@link PercentType} representing the input value, constrained to the range [0.0..100.0] + * @throws IllegalArgumentException if the value is outside the range [0.0..100.0] + */ + private static PercentType zPercentTypeFrom(double value) throws IllegalArgumentException { + if (!Double.isFinite(value) || value < 0.0 || value > 100.0) { + throw new IllegalArgumentException("PercentType value must be in range [0.0..100.0]: " + value); + } + return new PercentType(new BigDecimal(value)); + } + + /** + * Internal: create and return a copy of the given {@link HSBType}. + * + * @param hsb the {@link HSBType} to copy. + * @return a copy of the given {@link HSBType}. + */ + private static HSBType copyHsb(HSBType hsb) { + return new HSBType(hsb.getHue(), hsb.getSaturation(), hsb.getBrightness()); + } + + /* + * ================================================================================ + * SECTION: Internal private classes. + * ================================================================================ + */ + + /** + * Internal: a class that models the RGB LED sub-components of a white LED light. The RGB component + * weightings are in the range [0.0..1.0] which if scaled to 255 would produce the color temperature + * specified in the constructor at 100% brightness. + * + */ + protected static class WhiteLED { + + private final double[] profile; + private final double mirek; + + /** + * Converts the given Mirek/Mired color temperature to RGB component weighting for the LED, so that its + * output would have the specified color temperature. Each component is in the range [0.0..1.0] + * + * @param ledMirek the color temperature of the LED in Mirek/Mired. + */ + protected WhiteLED(double ledMirek) { + this.profile = Arrays + .stream(ColorUtil.hsbToRgbPercent(ColorUtil.xyToHsb(ColorUtil.kelvinToXY((1000000 / ledMirek))))) + .mapToDouble(p -> p.doubleValue() / 100).toArray(); + this.mirek = ledMirek; + } + + /** + * Get the Mirek/Mired color temperature of the LED. + * + * @return the Mirek/Mired color temperature of the LED. + */ + protected double getMirek() { + return mirek; + } + + /** + * Get the RGB component weighting of the LED. + * + * @return an array of 3 double values representing the RGB components of the LED in the range [0.0..1.0] + * which if scaled to 255 would produce the color temperature specified by the 'mirek' field at + * 100% brightness. + */ + protected double[] getProfile() { + return profile; + } + } + + /** + * Internal: a class containing mathematical utility methods that convert between RGB and RGBCW color arrays + * based on the RGB main values and the RGB sub- component values of the cool and warm white LEDs. + * + * Note: it is intended to move this class to the {@link ColorUtil} utility class, but let's keep it here + * for the time being in order to simplify testing and code review. + */ + public static class RgbcwMath { + + // below this value no RGB -> RGBCW conversion attempted (see method rgb2rgbcw) + private static final double CONVERSION_THRESHOLD = 0.01; + + // step size when iterating over C scalar values for RGB -> RGBCW conversion (see method rgb2rgbcw) + private static final double CONVERSION_ITERATOR_STEP_SIZE = 0.01; + + // default cool and warm white LED RGB profiles used if nothing else is provided in the variable argument lists + private static final double[] COOL_PROFILE = new double[] { 0.95562, 0.976449753, 1.0 }; // 153 Mirek/Mired + private static final double[] WARM_PROFILE = new double[] { 1.0, 0.695614289308524, 0.25572 }; // 500 + + /** + * Composes an RGBCW from the given RGB. Calls {@link #rgb2rgbcw(double[], double[], double[])} with default + * LED profiles. The result depends on the main input RGB values and the RGB sub- component contributions of + * the cold and warm white LEDs. It solves to find the maximum usable C and W scalar values such that none of + * the RGB' channels become negative. It solves for C and W such that: + *
+ * {@code RGB ≈ C * coolProfile + W * warmProfile + RGB'} where {@code RGB'} is the remaining RGB after + * subtracting the scaled cool and warm LED contributions. + *
+ * + * @param rgb a 3-element array of double: [R,G,B]. + * + * @return a 5-element array of double: [R',G',B',C,W], where R', G', B' are the remaining RGB values + * and C and W are the calculated cold and warm white values. + * @throws IllegalArgumentException if the input array length is not 3, or if any of its values are outside + * the range [0.0..1.0] + */ + public static double[] rgb2rgbcw(double[] rgb) throws IllegalArgumentException { + return rgb2rgbcw(rgb, COOL_PROFILE, WARM_PROFILE); + } + + /** + * Composes an RGBCW from the given RGB. The result depends on the main input RGB values and the RGB sub- + * component contributions of the cold and warm white LEDs. It solves to find the maximum usable C and W + * scalar values such that none of the RGB' channels become negative. It solves for C and W such that: + *
+ * {@code RGB ≈ C * coolProfile + W * warmProfile + RGB'} where {@code RGB'} is the remaining RGB after + * subtracting the scaled cool and warm LED contributions. + *
+ * + * @param rgb a 3-element array of double: [R,G,B]. + * @param coolProfile the cool white LED RGB profile, a normalized 3-element [R,G,B] array in the range + * [0.0..1.0]. For example see {@link #COOL_PROFILE}. + * @param warmProfile the warm white LED RGB profile, a normalized 3-element [R,G,B] array in the range + * [0.0..1.0]. For example see {@link #WARM_PROFILE}. + * + * @return a 5-element array of double: [R',G',B',C,W], where R', G', B' are the remaining RGB values + * and C and W are the calculated cold and warm white values. + * @throws IllegalArgumentException if the input array length is not 3, or if any of its values are outside + * the range [0.0..1.0] + */ + public static double[] rgb2rgbcw(double[] rgb, double[] coolProfile, double[] warmProfile) + throws IllegalArgumentException { + if (rgb.length != 3 || Arrays.stream(rgb).anyMatch(d -> !Double.isFinite(d) || d < 0.0 || d > 1.0)) { + throw new IllegalArgumentException("RGB invalid length, or value out of range"); + } + if (coolProfile.length != 3 || warmProfile.length != 3 + || Arrays.stream(coolProfile).anyMatch(d -> !Double.isFinite(d) || d < 0.0 || d > 1.0) + || Arrays.stream(warmProfile).anyMatch(d -> !Double.isFinite(d) || d < 0.0 || d > 1.0)) { + throw new IllegalArgumentException("Cool or warm profile invalid length, or value out of range"); + } + + double[] rgbcw = new double[] { rgb[0], rgb[1], rgb[2], 0.0, 0.0 }; + + // cool/warm contribution is only possible if all rgb values are non- zero + if (rgb[0] < CONVERSION_THRESHOLD || rgb[1] < CONVERSION_THRESHOLD || rgb[2] < CONVERSION_THRESHOLD) { + return rgbcw; + } + + double lowestDelta = 3.0; // lowest total of RGB' elements found so far; starting with the worst case + + // get maximum C scalar such that RGB' channels can't become negative + double coolScalarMax = getMaxScalarForRgbWithProfile(rgb, coolProfile); + + // iterate downwards over C scalar values to solve for the best combination of C and W scalars + for (double coolScalar = coolScalarMax; coolScalar >= 0.0; coolScalar -= CONVERSION_ITERATOR_STEP_SIZE) { + // subtract cool LED profile contributions from RGB to create RGB' + double[] rgbPrime = new double[] { // + rgb[0] - coolProfile[0] * coolScalar, // + rgb[1] - coolProfile[1] * coolScalar, // + rgb[2] - coolProfile[2] * coolScalar, // + Double.NaN, Double.NaN }; // scalar values are dropped in when a new best solution is found + + // get maximum W scalar such that RGB' channels can't become negative + double warmScalar = getMaxScalarForRgbWithProfile(rgbPrime, warmProfile); + + // also subtract warm LED profile contributions from RGB' + rgbPrime[0] = rgbPrime[0] - warmProfile[0] * warmScalar; + rgbPrime[1] = rgbPrime[1] - warmProfile[1] * warmScalar; + rgbPrime[2] = rgbPrime[2] - warmProfile[2] * warmScalar; + + // select the best solution so far that minimizes the total of the RGB' elements + double thisDelta = rgbPrime[0] + rgbPrime[1] + rgbPrime[2]; + if (thisDelta < lowestDelta) { + lowestDelta = thisDelta; + rgbcw = rgbPrime; + rgbcw[3] = coolScalar; // drop in the current C and W scalar values + rgbcw[4] = warmScalar; + } + } + + return rgbcw; + } + + /** + * Decomposes the given RGBCW to an RGB. Calls {@link #rgbcw2rgb(double[], double[], double[])} with default + * LED profiles. The result comprises the main input RGB values plus the RGB sub- component contributions of + * the cold and warm white LEDs. + * + * @param rgbcw a 5-element array of double: [R,G,B,C,W], where R, G, B are the RGB values and C and W are + * the cold and warm white LED RGB profile contributions. + * + * @return double[] a 3-element array of double: [R,G,B]. + * @throws IllegalArgumentException if the input array length is not 5, or if any its values are + * outside the range [0.0..1.0] + */ + public static double[] rgbcw2rgb(double[] rgbcw) throws IllegalArgumentException { + return rgbcw2rgb(rgbcw, COOL_PROFILE, WARM_PROFILE); + } + + /** + * Decomposes the given RGBCW to an RGB. The result comprises the main input RGB values plus the RGB sub- + * component contributions of the cold and warm white LEDs. + * + * @param rgbcw a 5-element array of double: [R,G,B,C,W], where R, G, B are the RGB values and C and W are + * the cold and warm white LED RGB profile contributions. + * @param coolProfile the cool white LED RGB profile, a normalized 3-element [R,G,B] array in the range + * [0.0..1.0]. For example see {@link #COOL_PROFILE}. + * @param warmProfile the warm white LED RGB profile, a normalized 3-element [R,G,B] array in the range + * [0.0..1.0]. For example see {@link #WARM_PROFILE}. + * + * @return double[] a 3-element array of double: [R,G,B]. + * @throws IllegalArgumentException if the input array length is not 5, or if any its values are + * outside the range [0.0..1.0] + */ + public static double[] rgbcw2rgb(double[] rgbcw, double[] coolProfile, double[] warmProfile) + throws IllegalArgumentException { + if (rgbcw.length != 5 || Arrays.stream(rgbcw).anyMatch(d -> !Double.isFinite(d) || d < 0.0 || d > 1.0)) { + throw new IllegalArgumentException("RGB invalid length, or value out of range"); + } + + double coolScalar = rgbcw[3], warmScalar = rgbcw[4]; + + // add c/w contributions to rgb and clamp to 1.0 + return new double[] { // + Math.min(1, rgbcw[0] + coolProfile[0] * coolScalar + warmProfile[0] * warmScalar), // + Math.min(1, rgbcw[1] + coolProfile[1] * coolScalar + warmProfile[1] * warmScalar), // + Math.min(1, rgbcw[2] + coolProfile[2] * coolScalar + warmProfile[2] * warmScalar) }; + } + + /** + * Internal: Returns the maximum scalar value for the given RGB and LED profile such that none of + * the resulting RGB' channels can become negative. Used to determine how much of a given white LED + * profile can be applied. It checks for zero profile values to avoid divide-by-zero errors. + * + * @param rgb a 3-element array of double: [R,G,B]. + * @param profile a 3-element array of double representing an LED profile: [R,G,B]. + * @return double representing the highest scalar value that can be applied to the given RGB LED profile values + * without any of the resulting RGB' channel values becoming negative. + */ + private static double getMaxScalarForRgbWithProfile(double[] rgb, double[] profile) { + return Math.min(Math.min( // + profile[0] > 0 ? rgb[0] / profile[0] : 1, // + profile[1] > 0 ? rgb[1] / profile[1] : 1), // + profile[2] > 0 ? rgb[2] / profile[2] : 1); + } + } +} diff --git a/bundles/org.openhab.core/src/test/java/org/openhab/core/util/LightModelTest.java b/bundles/org.openhab.core/src/test/java/org/openhab/core/util/LightModelTest.java new file mode 100644 index 000000000..c56e3314e --- /dev/null +++ b/bundles/org.openhab.core/src/test/java/org/openhab/core/util/LightModelTest.java @@ -0,0 +1,917 @@ +/* + * Copyright (c) 2010-2026 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.core.util; + +import static org.junit.jupiter.api.Assertions.*; + +import java.util.Arrays; + +import org.eclipse.jdt.annotation.NonNullByDefault; +import org.junit.jupiter.api.Test; +import org.openhab.core.library.types.DecimalType; +import org.openhab.core.library.types.HSBType; +import org.openhab.core.library.types.IncreaseDecreaseType; +import org.openhab.core.library.types.OnOffType; +import org.openhab.core.library.types.PercentType; +import org.openhab.core.library.types.QuantityType; +import org.openhab.core.library.unit.Units; +import org.openhab.core.types.UnDefType; +import org.openhab.core.util.LightModel.LedOperatingMode; +import org.openhab.core.util.LightModel.LightCapabilities; +import org.openhab.core.util.LightModel.RgbDataType; +import org.openhab.core.util.LightModel.RgbcwMath; +import org.openhab.core.util.LightModel.WhiteLED; + +/** + * Unit tests for {@link LightModel}. + * + * @author Andrew Fiddian-Green - Initial contribution + */ +@NonNullByDefault +public class LightModelTest { + + private static final double EPSILON = 1e-6; + private final WhiteLED coolWhiteLed = new WhiteLED(153); + private final WhiteLED warmWhiteLed = new WhiteLED(500); + + @Test + public void testFullColor() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT); + assertTrue(lsm.configGetLightCapabilities().supportsColor()); + assertTrue(lsm.configGetLightCapabilities().supportsBrightness()); + assertTrue(lsm.configGetLightCapabilities().supportsColorTemperature()); + + lsm.handleCommand(HSBType.RED); + assertEquals(HSBType.RED, lsm.getColor()); + assertEquals(PercentType.HUNDRED, lsm.getBrightness(true)); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + + lsm.handleCommand(new PercentType(50)); + assertEquals(new PercentType(50), lsm.getBrightness(true)); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + + lsm.handleCommand(PercentType.ZERO); + assertEquals(PercentType.ZERO, lsm.getBrightness(true)); + assertEquals(OnOffType.OFF, lsm.getOnOff(true)); + + lsm.handleCommand(IncreaseDecreaseType.INCREASE); + assertEquals(new PercentType(10), lsm.getBrightness(true)); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + + lsm.handleCommand(OnOffType.OFF); + assertEquals(OnOffType.OFF, lsm.getOnOff(true)); + assertEquals(PercentType.ZERO, lsm.getBrightness(true)); + + lsm.handleCommand(OnOffType.OFF); + assertEquals(OnOffType.OFF, lsm.getOnOff(true)); + assertEquals(PercentType.ZERO, lsm.getBrightness(true)); + + lsm.handleCommand(OnOffType.ON); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + assertEquals(new PercentType(10), lsm.getBrightness(true)); + + lsm.handleCommand(OnOffType.ON); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + assertEquals(new PercentType(10), lsm.getBrightness(true)); + + lsm.handleCommand(QuantityType.valueOf(500, Units.MIRED)); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + assertEquals(new PercentType(10), lsm.getBrightness(true)); + assertEquals(QuantityType.valueOf(500, Units.MIRED), lsm.getColorTemperature()); + assertEquals(PercentType.HUNDRED, lsm.getColorTemperaturePercent()); + + lsm.handleColorTemperatureCommand(QuantityType.valueOf(500, Units.MIRED)); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + assertEquals(new PercentType(10), lsm.getBrightness(true)); + assertEquals(QuantityType.valueOf(500, Units.MIRED), lsm.getColorTemperature()); + assertEquals(PercentType.HUNDRED, lsm.getColorTemperaturePercent()); + + lsm.handleColorTemperatureCommand(PercentType.ZERO); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + assertEquals(new PercentType(10), lsm.getBrightness(true)); + assertEquals(QuantityType.valueOf(153, Units.MIRED), lsm.getColorTemperature()); + assertEquals(PercentType.ZERO, lsm.getColorTemperaturePercent()); + } + + @Test + public void testColorWithoutColorTemperature() { + LightModel lsm = new LightModel(LightCapabilities.COLOR, RgbDataType.DEFAULT); + assertTrue(lsm.configGetLightCapabilities().supportsColor()); + assertTrue(lsm.configGetLightCapabilities().supportsBrightness()); + assertFalse(lsm.configGetLightCapabilities().supportsColorTemperature()); + + lsm.handleCommand(HSBType.RED); + assertEquals(HSBType.RED, lsm.getColor()); + assertEquals(PercentType.HUNDRED, lsm.getBrightness(true)); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + + lsm.handleCommand(QuantityType.valueOf(500, Units.MIRED)); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + assertEquals(PercentType.HUNDRED, lsm.getBrightness(true)); + assertNull(lsm.getColorTemperature()); + assertNull(lsm.getColorTemperaturePercent()); + assertEquals(UnDefType.UNDEF, lsm.toNonNull(lsm.getColorTemperature())); + assertEquals(UnDefType.UNDEF, lsm.toNonNull(lsm.getColorTemperaturePercent())); + + lsm.handleCommand(PercentType.ZERO); + assertEquals(PercentType.ZERO, lsm.getBrightness(true)); + assertEquals(OnOffType.OFF, lsm.getOnOff(true)); + } + + @Test + public void testBrightnessAndColorTemperature() { + LightModel lsm = new LightModel(LightCapabilities.BRIGHTNESS_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT); + assertFalse(lsm.configGetLightCapabilities().supportsColor()); + assertTrue(lsm.configGetLightCapabilities().supportsBrightness()); + assertTrue(lsm.configGetLightCapabilities().supportsColorTemperature()); + + lsm.handleCommand(HSBType.RED); + assertNull(lsm.getColor()); + assertEquals(PercentType.HUNDRED, lsm.getBrightness()); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + assertEquals(UnDefType.UNDEF, lsm.toNonNull(lsm.getColor())); + + lsm.handleCommand(QuantityType.valueOf(500, Units.MIRED)); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + assertEquals(QuantityType.valueOf(500, Units.MIRED), lsm.getColorTemperature()); + assertEquals(PercentType.HUNDRED, lsm.getColorTemperaturePercent()); + + lsm.handleCommand(PercentType.ZERO); + assertEquals(PercentType.ZERO, lsm.getBrightness()); + assertEquals(OnOffType.OFF, lsm.getOnOff(true)); + } + + @Test + public void testBrightnessOnly() { + LightModel lsm = new LightModel(LightCapabilities.BRIGHTNESS, RgbDataType.DEFAULT); + assertFalse(lsm.configGetLightCapabilities().supportsColor()); + assertTrue(lsm.configGetLightCapabilities().supportsBrightness()); + assertFalse(lsm.configGetLightCapabilities().supportsColorTemperature()); + + lsm.handleCommand(HSBType.RED); + assertNull(lsm.getColor()); + assertEquals(PercentType.HUNDRED, lsm.getBrightness()); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + assertEquals(UnDefType.UNDEF, lsm.toNonNull(lsm.getColor())); + + lsm.handleCommand(QuantityType.valueOf(500, Units.MIRED)); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + assertNull(lsm.getColorTemperature()); + assertNull(lsm.getColorTemperaturePercent()); + + lsm.handleCommand(PercentType.ZERO); + assertEquals(PercentType.ZERO, lsm.getBrightness()); + assertEquals(OnOffType.OFF, lsm.getOnOff(true)); + } + + @Test + public void testOnOffOnly() { + LightModel lsm = new LightModel(LightCapabilities.ON_OFF, RgbDataType.DEFAULT); + assertFalse(lsm.configGetLightCapabilities().supportsColor()); + assertFalse(lsm.configGetLightCapabilities().supportsBrightness()); + assertFalse(lsm.configGetLightCapabilities().supportsColorTemperature()); + + lsm.handleCommand(HSBType.RED); + assertNull(lsm.getColor()); + assertNull(lsm.getBrightness()); + assertEquals(OnOffType.ON, lsm.getOnOff()); + assertEquals(UnDefType.UNDEF, lsm.toNonNull(lsm.getColor())); + assertEquals(UnDefType.UNDEF, lsm.toNonNull(lsm.getBrightness())); + + lsm.handleCommand(QuantityType.valueOf(500, Units.MIRED)); + assertEquals(OnOffType.ON, lsm.getOnOff()); + assertNull(lsm.getColorTemperature()); + assertNull(lsm.getColorTemperaturePercent()); + + lsm.handleCommand(PercentType.ZERO); + assertNull(lsm.getBrightness()); + assertEquals(OnOffType.OFF, lsm.getOnOff()); + } + + @Test + public void testColorTemperatureTracking() { + LightModel lsm = new LightModel(); + + lsm.handleCommand(HSBType.RED); + assertEquals(HSBType.RED, lsm.getColor()); + assertEquals(PercentType.HUNDRED, lsm.getBrightness(true)); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + + lsm.handleCommand(QuantityType.valueOf(500, Units.MIRED)); + assertNotEquals(HSBType.RED, lsm.getColor()); + assertEquals(PercentType.HUNDRED, lsm.getBrightness(true)); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + assertEquals(QuantityType.valueOf(500, Units.MIRED), lsm.getColorTemperature()); + assertEquals(PercentType.HUNDRED, lsm.getColorTemperaturePercent()); + + lsm.handleCommand(QuantityType.valueOf(153, Units.MIRED)); + assertEquals(QuantityType.valueOf(153, Units.MIRED), lsm.getColorTemperature()); + assertEquals(PercentType.ZERO, lsm.getColorTemperaturePercent()); + + lsm.handleColorTemperatureCommand(PercentType.HUNDRED); + assertEquals(QuantityType.valueOf(500, Units.MIRED), lsm.getColorTemperature()); + assertEquals(PercentType.HUNDRED, lsm.getColorTemperaturePercent()); + + lsm.handleColorTemperatureCommand(PercentType.ZERO); + assertEquals(QuantityType.valueOf(153, Units.MIRED), lsm.getColorTemperature()); + assertEquals(PercentType.ZERO, lsm.getColorTemperaturePercent()); + } + + @Test + public void testSimpleConstructor() { + LightModel lsm = new LightModel(); + assertTrue(lsm.configGetLightCapabilities().supportsColor()); + assertTrue(lsm.configGetLightCapabilities().supportsBrightness()); + assertTrue(lsm.configGetLightCapabilities().supportsColorTemperature()); + assertEquals(1.0, lsm.configGetMinimumOnBrightness()); + assertEquals(500.0, lsm.configGetMirekControlWarmest()); + assertEquals(153.0, lsm.configGetMirekControlCoolest()); + assertEquals(10.0, lsm.configGetIncreaseDecreaseStep()); + } + + @Test + public void testComplexConstructor() { + LightModel lsm = new LightModel(LightCapabilities.ON_OFF, RgbDataType.RGB_C_W, 2.0, 154.0, 501.0, 11.0, null, + null); + assertFalse(lsm.configGetLightCapabilities().supportsColor()); + assertFalse(lsm.configGetLightCapabilities().supportsBrightness()); + assertFalse(lsm.configGetLightCapabilities().supportsColorTemperature()); + assertEquals(RgbDataType.RGB_C_W, lsm.configGetRgbDataType()); + assertEquals(2.0, lsm.configGetMinimumOnBrightness()); + assertEquals(501.0, lsm.configGetMirekControlWarmest()); + assertEquals(154.0, lsm.configGetMirekControlCoolest()); + assertEquals(11.0, lsm.configGetIncreaseDecreaseStep()); + } + + @Test + public void testCapabilitySetters() { + LightModel lsm = new LightModel(); + lsm.configSetLightCapabilities(LightCapabilities.ON_OFF); + assertFalse(lsm.configGetLightCapabilities().supportsColor()); + assertFalse(lsm.configGetLightCapabilities().supportsBrightness()); + assertFalse(lsm.configGetLightCapabilities().supportsColorTemperature()); + + lsm.configSetLightCapabilities(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE); + assertTrue(lsm.configGetLightCapabilities().supportsColor()); + assertTrue(lsm.configGetLightCapabilities().supportsBrightness()); + assertTrue(lsm.configGetLightCapabilities().supportsColorTemperature()); + } + + @Test + public void testParameterSetters() { + LightModel lsm = new LightModel(); + lsm.configSetRgbDataType(RgbDataType.RGB_C_W); + lsm.configSetMinimumOnBrightness(2.0); + lsm.configSetMirekControlWarmest(501.0); + lsm.configSetMirekControlCoolest(154.0); + lsm.configSetIncreaseDecreaseStep(11.0); + + assertEquals(RgbDataType.RGB_C_W, lsm.configGetRgbDataType()); + assertEquals(2.0, lsm.configGetMinimumOnBrightness()); + assertEquals(501.0, lsm.configGetMirekControlWarmest()); + assertEquals(154.0, lsm.configGetMirekControlCoolest()); + assertEquals(11.0, lsm.configGetIncreaseDecreaseStep()); + } + + @Test + public void testParameterSettersBad() { + LightModel lsm = new LightModel(); + assertThrows(IllegalArgumentException.class, () -> lsm.configSetMinimumOnBrightness(0.0)); + assertThrows(IllegalArgumentException.class, () -> lsm.configSetMinimumOnBrightness(11.0)); + + assertThrows(IllegalArgumentException.class, () -> lsm.configSetMirekControlWarmest(153.0)); + assertThrows(IllegalArgumentException.class, () -> lsm.configSetMirekControlWarmest(99.0)); + assertThrows(IllegalArgumentException.class, () -> lsm.configSetMirekControlWarmest(1001.0)); + + assertThrows(IllegalArgumentException.class, () -> lsm.configSetMirekControlCoolest(501.0)); + assertThrows(IllegalArgumentException.class, () -> lsm.configSetMirekControlCoolest(99.0)); + assertThrows(IllegalArgumentException.class, () -> lsm.configSetMirekControlCoolest(1001.0)); + + assertThrows(IllegalArgumentException.class, () -> lsm.configSetIncreaseDecreaseStep(0.0)); + assertThrows(IllegalArgumentException.class, () -> lsm.configSetIncreaseDecreaseStep(51.0)); + } + + @Test + public void testCommandsBad() { + LightModel lsm = new LightModel(); + assertThrows(IllegalArgumentException.class, () -> lsm.handleCommand(DecimalType.ZERO)); + assertThrows(IllegalArgumentException.class, () -> lsm.handleCommand(QuantityType.valueOf(5, Units.AMPERE))); + assertThrows(IllegalArgumentException.class, + () -> lsm.handleColorTemperatureCommand(QuantityType.valueOf(5, Units.AMPERE))); + assertDoesNotThrow(() -> lsm.handleColorTemperatureCommand(OnOffType.ON)); + } + + @Test + public void testComplexConstructorBad() { + assertThrows(IllegalArgumentException.class, + () -> new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT, 0.0, null, + null, null, null, null)); + + assertThrows(IllegalArgumentException.class, + () -> new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT, 11.0, null, + null, null, null, null)); + + assertThrows(IllegalArgumentException.class, + () -> new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT, null, null, + 99.0, null, null, null)); + + assertThrows(IllegalArgumentException.class, + () -> new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT, null, null, + 1001.0, null, null, null)); + + assertThrows(IllegalArgumentException.class, + () -> new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT, null, 99.0, + null, null, null, null)); + + assertThrows(IllegalArgumentException.class, + () -> new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT, null, 1001.0, + null, null, null, null)); + + assertThrows(IllegalArgumentException.class, + () -> new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT, null, 300.0, + 300.0, null, null, null)); + + assertThrows(IllegalArgumentException.class, + () -> new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT, null, null, + null, 0.0, null, null)); + + assertThrows(IllegalArgumentException.class, + () -> new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT, null, null, + null, 51.0, null, null)); + + assertThrows(IllegalArgumentException.class, + () -> new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT, null, null, + null, null, 99.0, null)); + + assertThrows(IllegalArgumentException.class, + () -> new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT, null, null, + null, null, null, 10001.0)); + } + + @Test + public void testRgbIgnoreBrightness() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_NO_BRIGHTNESS); + assertTrue(lsm.configGetLightCapabilities().supportsColor()); + assertTrue(lsm.configGetLightCapabilities().supportsBrightness()); + assertTrue(lsm.configGetLightCapabilities().supportsColorTemperature()); + + lsm.handleCommand(HSBType.RED); + assertEquals(HSBType.RED, lsm.getColor()); + assertEquals(PercentType.HUNDRED, lsm.getBrightness(true)); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + + double[] rgb = lsm.getRGBx(); + assertEquals(3, rgb.length); + assertEquals(255.0, rgb[0], 1); + + lsm.handleCommand(new PercentType(50)); + rgb = lsm.getRGBx(); + assertEquals(3, rgb.length); + assertEquals(255.0, rgb[0], 1); + + /* + * Nota Bene: in the case of rgbIgnoreBrightness == true the round trip setRGBx() followed by + * getRGBx will NOT return identical values. But the ratio of the RGB values WILL be the same. + */ + lsm.setRGBx(new double[] { 0.0, 100.0, 200.0 }); + rgb = lsm.getRGBx(); + assertEquals(3, rgb.length); + assertEquals(0.0, rgb[0], 1); + assertEquals(127.5, rgb[1], 1); + assertEquals(255.0, rgb[2], 1); + } + + @Test + public void testRgb() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT); + assertTrue(lsm.configGetLightCapabilities().supportsColor()); + assertTrue(lsm.configGetLightCapabilities().supportsBrightness()); + assertTrue(lsm.configGetLightCapabilities().supportsColorTemperature()); + + lsm.handleCommand(HSBType.RED); + assertEquals(HSBType.RED, lsm.getColor()); + assertEquals(PercentType.HUNDRED, lsm.getBrightness(true)); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + + double[] rgb = lsm.getRGBx(); + assertEquals(3, rgb.length); + assertEquals(255.0, rgb[0], 1); + + lsm.handleCommand(new PercentType(50)); + rgb = lsm.getRGBx(); + assertEquals(3, rgb.length); + assertEquals(127.5, rgb[0], 1); + + lsm.setRGBx(new double[] { 0.0, 100.0, 200.0 }); + rgb = lsm.getRGBx(); + assertEquals(3, rgb.length); + assertEquals(0.0, rgb[0], 1); + assertEquals(100.0, rgb[1], 1); + assertEquals(200.0, rgb[2], 1); + PercentType brightness = lsm.getBrightness(true); + assertNotNull(brightness); + assertEquals(78.4, brightness.doubleValue(), 1); // 78.4 = 200 / 255 + } + + @Test + public void testRgbwDimming() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_W); + assertTrue(lsm.configGetLightCapabilities().supportsColor()); + assertTrue(lsm.configGetLightCapabilities().supportsBrightness()); + assertTrue(lsm.configGetLightCapabilities().supportsColorTemperature()); + + lsm.handleCommand(HSBType.RED); + assertEquals(HSBType.RED, lsm.getColor()); + assertEquals(PercentType.HUNDRED, lsm.getBrightness(true)); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + + double[] rgbw = lsm.getRGBx(); + assertEquals(4, rgbw.length); + assertEquals(255.0, rgbw[0], 1); + + lsm.handleCommand(new PercentType(50)); + rgbw = lsm.getRGBx(); + assertEquals(4, rgbw.length); + assertEquals(127.5, rgbw[0], 1); + + lsm.setRGBx(new double[] { 0.0, 100.0, 200.0, 55.0 }); // set full brightness 200 + 55 = 255 + + rgbw = lsm.getRGBx(); + assertEquals(4, rgbw.length); + assertEquals(0.0, rgbw[0], 1); + assertEquals(100.0, rgbw[1], 1); + assertEquals(200.0, rgbw[2], 1); + assertEquals(55.0, rgbw[3], 1); + PercentType brightness = lsm.getBrightness(true); + assertNotNull(brightness); + assertEquals(PercentType.HUNDRED, brightness); + + lsm.setRGBx(new double[] { 0.0, 100.0, 200.0, 0.0 }); + brightness = lsm.getBrightness(true); + assertNotNull(brightness); + assertEquals(78.4, brightness.doubleValue(), 1); // 78.4 = 200 / 255 + + lsm.setRGBx(new double[] { 0.0, 100.0, 100.0, 100.0 }); + brightness = lsm.getBrightness(true); + assertNotNull(brightness); + assertEquals(78.4, brightness.doubleValue(), 1); // 78.4 = 200 / 255 + } + + @Test + public void testSparseChannelRuleCompliance() { + LightModel lsm; + + // supports color + lsm = new LightModel(LightCapabilities.COLOR, RgbDataType.DEFAULT); + lsm.handleCommand(HSBType.RED); + + assertEquals(HSBType.RED, lsm.getColor()); + assertNull(lsm.getBrightness()); + assertNull(lsm.getOnOff()); + assertNotNull(lsm.getBrightness(true)); + assertNotNull(lsm.getOnOff(true)); + + // supports brightness + lsm = new LightModel(LightCapabilities.BRIGHTNESS, RgbDataType.DEFAULT); + lsm.handleCommand(HSBType.RED); + + assertNull(lsm.getColor()); + assertNotNull(lsm.getBrightness()); + assertNull(lsm.getOnOff()); + assertNotNull(lsm.getOnOff(true)); + + // supports on/off + lsm = new LightModel(LightCapabilities.ON_OFF, RgbDataType.DEFAULT); + lsm.handleCommand(HSBType.RED); + + assertNull(lsm.getColor()); + assertNull(lsm.getBrightness()); + assertNotNull(lsm.getOnOff()); + } + + @Test + public void testRgbToRgbcwToRgb_RoundTrip() { + double[] originalRgb = { 0.8, 0.7, 0.6 }; + double[] rgbcw = RgbcwMath.rgb2rgbcw(originalRgb, coolWhiteLed.getProfile(), warmWhiteLed.getProfile()); + double[] reconstructedRgb = RgbcwMath.rgbcw2rgb(rgbcw, coolWhiteLed.getProfile(), warmWhiteLed.getProfile()); + assertArrayEquals(originalRgb, reconstructedRgb, EPSILON, "RGB → RGBCW → RGB should match original"); + } + + @Test + public void testRgbcwToRgbToRgbcw_RoundTrip() { + double[] originalRgbcw = { 0.5, 0.4, 0.3, 0.2, 0.1 }; + double[] rgb = RgbcwMath.rgbcw2rgb(originalRgbcw, coolWhiteLed.getProfile(), warmWhiteLed.getProfile()); + double[] reconstructedRgbcw = RgbcwMath.rgb2rgbcw(rgb, coolWhiteLed.getProfile(), warmWhiteLed.getProfile()); + double[] rgb2 = RgbcwMath.rgbcw2rgb(reconstructedRgbcw, coolWhiteLed.getProfile(), warmWhiteLed.getProfile()); + assertArrayEquals(rgb, rgb2, EPSILON, "RGB reconstructed from RGBCW should remain visually consistent"); + } + + @Test + public void testEdgeCase_FullWhite() { + double[] white = { 1.0, 1.0, 1.0 }; + double[] rgbcw = RgbcwMath.rgb2rgbcw(white, coolWhiteLed.getProfile(), warmWhiteLed.getProfile()); + double[] reconstructed = RgbcwMath.rgbcw2rgb(rgbcw, coolWhiteLed.getProfile(), warmWhiteLed.getProfile()); + assertArrayEquals(white, reconstructed, EPSILON, "Full white RGB should round-trip cleanly"); + } + + @Test + public void testEdgeCase_Black() { + double[] black = { 0.0, 0.0, 0.0 }; + double[] rgbcw = RgbcwMath.rgb2rgbcw(black, coolWhiteLed.getProfile(), warmWhiteLed.getProfile()); + double[] reconstructed = RgbcwMath.rgbcw2rgb(rgbcw, coolWhiteLed.getProfile(), warmWhiteLed.getProfile()); + assertArrayEquals(black, reconstructed, EPSILON, "Black RGB should round-trip cleanly"); + } + + @Test + public void testRgbcw() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + + lsm.handleCommand(HSBType.RED); + assertEquals(HSBType.RED, lsm.getColor()); + assertEquals(PercentType.HUNDRED, lsm.getBrightness(true)); + assertEquals(OnOffType.ON, lsm.getOnOff(true)); + + // primary red at 100% brightness + double[] rgbcw = lsm.getRGBx(); + assertEquals(5, rgbcw.length); + assertEquals(255.0, rgbcw[0], 1); + assertEquals(0.0, rgbcw[1], 1); + assertEquals(0.0, rgbcw[2], 1); + assertEquals(0.0, rgbcw[3], 1); + assertEquals(0.0, rgbcw[4], 1); + + // primary red at 50% brightness + lsm.handleCommand(new PercentType(50)); + rgbcw = lsm.getRGBx(); + assertEquals(5, rgbcw.length); + assertEquals(127.5, rgbcw[0], 1); + assertEquals(0.0, rgbcw[1], 1); + assertEquals(0.0, rgbcw[2], 1); + assertEquals(0.0, rgbcw[3], 1); + assertEquals(0.0, rgbcw[4], 1); + } + + /** + * Case: Primary Red + * Input (RGBCW): (255, 0, 0, 0, 0) + * Expected HSB: (0, 100, 100) + */ + @Test + public void testRgbcwPrimaryRed() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + lsm.setRGBx(new double[] { 255, 0, 0, 0, 0 }); + HSBType hsb = lsm.getColor(); + assertNotNull(hsb); + assertEquals(0.0, hsb.getHue().doubleValue(), 1); // hue for red + assertEquals(100.0, hsb.getSaturation().doubleValue(), 1); // fully saturated + assertEquals(100.0, hsb.getBrightness().doubleValue(), 1); // full brightness + } + + /** + * Case: Bright White (warm) + * Input (RGBCW): (0, 0, 0, 0, 255) + * Expected HSB: Depends on implementation. Expect it to match the color temperature of the warm white LED. + */ + @Test + public void testRgbcwBrightWhiteWarm() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + HSBType warm = ColorUtil.xyToHsb(ColorUtil.kelvinToXY(1000000 / lsm.configGetMirekWarmWhiteLed())); + lsm.setRGBx(new double[] { 0, 0, 0, 0, 255 }); + HSBType hsb = lsm.getColor(); + assertNotNull(hsb); + assertEquals(30.0, hsb.getHue().doubleValue(), 30); // in the warm spectrum (e.g., ~30) + assertEquals(warm.getHue().doubleValue(), hsb.getHue().doubleValue(), 1); // same as warm LED + assertEquals(warm.getSaturation().doubleValue(), hsb.getSaturation().doubleValue(), 1); // same as warm LED + assertEquals(warm.getBrightness().doubleValue(), hsb.getBrightness().doubleValue(), 1); // same as warm LED + } + + /** + * Case: Bright White (warm) + * Input (RGBCW): (0, 0, 0, 255, 0) + * Expected HSB: Depends on implementation. Expect it to match the color temperature of the cool white LED. + */ + @Test + public void testRgbcwBrightWhiteCool() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + HSBType cool = ColorUtil.xyToHsb(ColorUtil.kelvinToXY(1000000 / lsm.configGetMirekCoolWhiteLed())); + lsm.setRGBx(new double[] { 0, 0, 0, 255, 0 }); + HSBType hsb = lsm.getColor(); + assertNotNull(hsb); + assertEquals(240.0, hsb.getHue().doubleValue(), 30); // in the cool spectrum (e.g., ~240) + assertEquals(cool.getHue().doubleValue(), hsb.getHue().doubleValue(), 5); // same as cool LED + assertEquals(cool.getSaturation().doubleValue(), hsb.getSaturation().doubleValue(), 5); // same as cool LED + assertEquals(cool.getBrightness().doubleValue(), hsb.getBrightness().doubleValue(), 5); // same as cool LED + } + + /** + * Case: Mixed White (neutral) + * Input (RGBCW): (0, 0, 0, 255, 255) + * Expected HSB: The combined effect of cool and warm white should yield a neutral white. The resulting + * hue is not really relevant, so long as the saturation is very low, and the point in HSB space lies + * between the cool and warm white levels. + */ + @Test + public void testRgbcwMixedWhiteNeutral() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + /* + * for the purposes of this test we force + * - the cool white led must have a blue hue and ~20% saturation + * - the cool warm led must have a yellow hue and ~20% saturation + * - so the mix should have ~0% saturation (and the hue is undefined) + */ + lsm.configSetMirekCoolWhiteLED(100); // 10'000 K + lsm.configSetMirekWarmWhiteLED(230); // 4'347 K + + lsm.setRGBx(new double[] { 0, 0, 0, 255, 255 }); + HSBType hsb = lsm.getColor(); + assertNotNull(hsb); + assertEquals(180.0, hsb.getHue().doubleValue(), 180); // hue is not relevant + assertEquals(0.0, hsb.getSaturation().doubleValue(), 5); // saturation very low + assertEquals(100.0, hsb.getBrightness().doubleValue(), 1); // 100% brightness + } + + /** + * Case: Black + * Input (RGBCW): (0, 0, 0, 0, 0) + * Expected HSB: Black. The hue and saturation are undefined, and the brightness shall be zero. + */ + @Test + public void testRgbcwBlack() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + lsm.setRGBx(new double[] { 0, 0, 0, 0, 0 }); + HSBType hsb = lsm.getColor(); + assertNotNull(hsb); + assertEquals(180.0, hsb.getHue().doubleValue(), 180); // hue is not relevant + assertEquals(180.0, hsb.getSaturation().doubleValue(), 180); // saturation not relevant + assertEquals(0.0, hsb.getBrightness().doubleValue(), 1); // 0% brightness (black) + } + + /** + * Case: All channels max + * Input (RGBCW): (255, 255, 255, 255, 255) + * Expected HSB: White. The combination of all channels at full brightness should produce the brightest possible + * white, with zero saturation. + */ + @Test + public void testRgbcwAllChannelsMax() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + lsm.setRGBx(new double[] { 255, 255, 255, 255, 255 }); + HSBType hsb = lsm.getColor(); + assertNotNull(hsb); + assertEquals(180.0, hsb.getHue().doubleValue(), 180); // hue is not relevant + assertEquals(0.0, hsb.getSaturation().doubleValue(), 1); // zero saturation + assertEquals(100.0, hsb.getBrightness().doubleValue(), 1); // white at full brightness + } + + /** + * Case: Maximum RGB, zero white + * Input (RGBCW): (255, 255, 255, 0, 0) + * Expected HSB: White. The RGB channels alone should produce white at full brightness. + */ + @Test + public void testRgbcwMaxRgbZeroWhite() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + lsm.setRGBx(new double[] { 255, 255, 255, 0, 0 }); + HSBType hsb = lsm.getColor(); + assertNotNull(hsb); + assertEquals(180.0, hsb.getHue().doubleValue(), 180); // hue is not relevant + assertEquals(0.0, hsb.getSaturation().doubleValue(), 1); // zero saturation + assertEquals(100.0, hsb.getBrightness().doubleValue(), 1); // white at full brightness + } + + /** + * Case: Mixed color with white + * Input (RGBCW): (255, 0, 0, 0, 100) + * Expected HSB: A less saturated, warmer red. The Hue is in the positive red sector, saturation is lower, + * and brightness is 100%. + */ + @Test + public void testRgbcwMixedColorWithWhite() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + lsm.setRGBx(new double[] { 255, 0, 0, 0, 100 }); + HSBType hsb = lsm.getColor(); + assertNotNull(hsb); + assertEquals(15.0, hsb.getHue().doubleValue(), 15); // positive red + assertTrue(100.0 > hsb.getSaturation().doubleValue()); // less saturated + assertEquals(100.0, hsb.getBrightness().doubleValue()); // 100% + } + + /** + * Case: Non-zero RGB with CW only + * Input (RGBCW): (255, 0, 0, 100, 0) + * Expected HSB: A less saturated, cooler red. Hue is in the negative red sector, saturation is lower, + * and brightness is 100%. + */ + @Test + public void testRgbcwNonZeroRgbWithCoolWhiteOnly() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + // force cool white led for this test + lsm.configSetMirekCoolWhiteLED(100); // 10'000 K + lsm.setRGBx(new double[] { 255, 0, 0, 100, 0 }); + HSBType hsb = lsm.getColor(); + assertNotNull(hsb); + assertEquals(345.0, hsb.getHue().doubleValue(), 15); // negative red + assertTrue(100.0 > hsb.getSaturation().doubleValue()); // less saturated + assertEquals(100.0, hsb.getBrightness().doubleValue()); // 100% + } + + /** + * Case: Primary Blue + * Input (HSB): (240, 100, 100) + * Expected RGBCW: (0, 0, 255, 0, 0). A pure, saturated color should only use the RGB channels. + */ + @Test + public void testRgbcwPrimaryBlue() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + lsm.handleCommand(new HSBType("240,100,100")); + double[] rgbx = lsm.getRGBx(); + assertEquals(5, rgbx.length); + assertEquals(0.0, rgbx[0], 1); // no red + assertEquals(0.0, rgbx[1], 1); // no green + assertEquals(255.0, rgbx[2], 1); // full blue + assertEquals(0.0, rgbx[3], 1); // no cool white + assertEquals(0.0, rgbx[4], 1); // no warm white + } + + /** + * Case: Black #2 + * Input (HSB): (0, 0, 0) + * Expected RGBCW: Black should result in all channels off. + */ + @Test + public void testRgbcwBlack2() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + lsm.handleCommand(new HSBType("0,0,0")); + double[] rgbx = lsm.getRGBx(); + assertEquals(5, rgbx.length); + assertEquals(0.0, rgbx[0], 1); // no red + assertEquals(0.0, rgbx[1], 1); // no green + assertEquals(0.0, rgbx[2], 1); // no blue + assertEquals(0.0, rgbx[3], 1); // no cool white + assertEquals(0.0, rgbx[4], 1); // no warm white + } + + /** + * Case: Low Brightness, High Saturation + * Input (HSB): (60, 100, 25) + * Expected RGBCW: The low brightness should mean the white channels are not used at all, and only the RGB channels + * are used at a scaled-down value. + */ + @Test + public void testRgbcwLowBrightnessHighSaturation() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + lsm.handleCommand(new HSBType("60,100,25")); + double[] rgbx = lsm.getRGBx(); + assertEquals(5, rgbx.length); + assertEquals(64.0, rgbx[0], 1); // 25% of 255 red => yellow + assertEquals(64.0, rgbx[1], 1); // 25% of 255 green => yellow + assertEquals(0.0, rgbx[2], 1); // no blue + assertEquals(0.0, rgbx[3], 1); // no cool white + assertEquals(0.0, rgbx[4], 1); // no warm white + } + + /** + * Case: Gray + * Input (HSB): (0, 0, 50) + * Expected RGBCW: The gray should be produced by the cool or white LED, with minimal RGB to fine tune the color. + */ + @Test + public void testRgbcwGray() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + lsm.handleCommand(new HSBType("0,0,50")); + double[] rgbx = lsm.getRGBx(); + assertEquals(5, rgbx.length); + assertEquals(0.0, rgbx[0], 1); // red zero + assertEquals(0.0, rgbx[1], 1); // green zero + assertEquals(0.0, rgbx[2], 1); // blue zero + assertEquals(128.0, rgbx[3] + rgbx[4], 5); // ~50% brightness from cool + warm white + } + + /** + * Case: Pastel Green + * Input (HSB): (120, 50, 75) + * Expected RGBCW: The conversion should calculate a mix of green and white to achieve the desired brightness and + * saturation. The green channel should be prominent, with some contribution from the cool white LED to reduce + * saturation and increase brightness. + */ + @Test + public void testRgbcwPastelGreen() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + lsm.handleCommand(new HSBType("120,50,75")); + double[] rgbx = lsm.getRGBx(); + assertEquals(5, rgbx.length); + assertEquals(0.0, rgbx[0], 1); // red channel minimal + assertTrue(rgbx[1] > Math.max(rgbx[0], rgbx[2])); // green channel dominant + assertEquals(0.0, rgbx[2], 1); // blue channel minimal + assertEquals(0.0, Math.min(rgbx[0], rgbx[2]), 1); // red or blue should be zero + assertEquals(192.0, Arrays.stream(rgbx).sum(), 5); // ~75% brightness + } + + /** + * Case: Pastel Color (yellow, low saturation) + * Input (RGBCW): (100, 100, 0, 100, 100) + * Expected HSB: The high CW and WW values should lead to a desaturated, brighter version of the yellow from the RGB + * components. The saturation will be lower and the brightness higher than for a pure RGB yellow. + */ + @Test + public void testRgbcwPastelYellowLowSaturation() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + lsm.setRGBx(new double[] { 100, 100, 0, 100, 100 }); + HSBType hsb = lsm.getColor(); + assertNotNull(hsb); + assertEquals(60.0, hsb.getHue().doubleValue(), 1); // hue 60 (yellow) + assertEquals(50.0, hsb.getSaturation().doubleValue(), 10); // lower saturation than full yellow + assertEquals(100.0, hsb.getBrightness().doubleValue(), 1); // 100% brightness + } + + /** + * Case: HSB with Cool White preference + * Input (HSB): A color with a blue tint, e.g., (240, 50, 75). + * Expected RGBCW: The conversion should prioritize the cool white LED to create a cooler white component, resulting + * in a higher CW value and lower WW. + */ + @Test + public void testRgbcwHsbWithCoolWhitePreference() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + lsm.handleCommand(new HSBType("240,50,75")); + double[] rgbx = lsm.getRGBx(); + assertEquals(5, rgbx.length); + assertEquals(0.0, rgbx[0], 5); // red channel fine tuning up to 5.0 + assertEquals(0.0, rgbx[1], 5); // green channel fine tuning up to 5.0 + assertTrue(rgbx[2] > Math.max(rgbx[0], rgbx[1])); // blue channel dominant + assertTrue(rgbx[3] > rgbx[4]); // cool channel dominant over warm + assertEquals(192.0, Arrays.stream(rgbx).sum(), 5); // ~75% brightness + } + + /** + * Case: HSB with Warm White preference + * Input (HSB): A color with a yellow/red tint, e.g., (30, 70, 75). + * Expected RGBCW: The conversion should prioritize the warm white LED to maintain the warmer color temperature, + * resulting in a higher WW value and lower CW. + */ + @Test + public void testRgbcwHsbWithWarmWhitePreference() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + lsm.handleCommand(new HSBType("30,70,75")); + double[] rgbx = lsm.getRGBx(); + assertEquals(5, rgbx.length); + assertTrue(rgbx[0] > Math.max(rgbx[1], rgbx[2])); // red channel dominant + assertEquals(0.0, rgbx[1], 5); // green channel fine tuning up to 5.0 + assertEquals(0.0, rgbx[2], 5); // blue channel fine tuning up to 5.0 + assertTrue(rgbx[4] > rgbx[3]); // warm white channel dominant + assertEquals(192.0, Arrays.stream(rgbx).sum(), 10); // ~75% brightness + } + + /** + * Case: Full Bright White + * Input (HSB): (0, 0, 100) + * Expected RGBCW: (0, 0, 0, 255, 255). Maximum brightness and zero saturation should be achieved by using mainly + * the white channels, with small RGB values for color fine tuning. + */ + @Test + public void testRgbcwFullBrightWhite() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + lsm.handleCommand(new HSBType("0,0,100")); + double[] rgbx = lsm.getRGBx(); + assertEquals(5, rgbx.length); + assertEquals(0.0, rgbx[0], 5); // red channel fine tuning up to 5.0 + assertEquals(0.0, rgbx[1], 5); // green channel fine tuning up to 5.0 + assertEquals(0.0, rgbx[2], 5); // blue channel fine tuning up to 5.0 + assertTrue(rgbx[3] > rgbx[4]); // cool white channel dominant + } + + /** + * Case: Switch LED operation mode at runtime + */ + @Test + public void testSwitchLedOperationMode() { + LightModel lsm = new LightModel(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.RGB_C_W); + lsm.handleCommand(new HSBType("0,0,100")); + double[] rgbx = lsm.getRGBx(); + assertEquals(5, rgbx.length); + + lsm.setLedOperatingMode(LedOperatingMode.RGB_ONLY); + rgbx = lsm.getRGBx(); + assertEquals(5, rgbx.length); + assertEquals(0.0, rgbx[3], 0.01); + assertEquals(0.0, rgbx[4], 0.01); + + lsm.setLedOperatingMode(LedOperatingMode.WHITE_ONLY); + rgbx = lsm.getRGBx(); + assertEquals(5, rgbx.length); + assertEquals(0.0, rgbx[0], 0.01); + assertEquals(0.0, rgbx[1], 0.01); + assertEquals(0.0, rgbx[2], 0.01); + assertEquals(255.0, rgbx[3] + rgbx[4], 0.01); + } +}