From a51e7f9910eb11cdba2173d78e292e0e7714a7ed Mon Sep 17 00:00:00 2001 From: Andrew Fiddian-Green Date: Sun, 29 Mar 2026 13:56:56 +0100 Subject: [PATCH] State machine to model lights in Thing handlers (#4995) Signed-off-by: Andrew Fiddian-Green Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- .../thing/binding/BaseLightThingHandler.java | 228 +++ .../org/openhab/core/util/LightModel.java | 1536 +++++++++++++++++ .../org/openhab/core/util/LightModelTest.java | 917 ++++++++++ 3 files changed, 2681 insertions(+) create mode 100644 bundles/org.openhab.core.thing/src/main/java/org/openhab/core/thing/binding/BaseLightThingHandler.java create mode 100644 bundles/org.openhab.core/src/main/java/org/openhab/core/util/LightModel.java create mode 100644 bundles/org.openhab.core/src/test/java/org/openhab/core/util/LightModelTest.java 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: + *

+ * It maintains an internal representation of the state of the light. + * It provides methods to handle commands from openHAB and to update the state from the remote light. + * It also provides configuration methods to set the capabilities and parameters of the light. + * The state machine maintains a consistent state, ensuring that the On/Off state is derived from the + * brightness, and that the color temperature and color are only set if the capabilities are supported. + * It also provides utility methods to convert between different color representations. + *

+ * See also {@link ColorUtil} for other color conversions. + *

+ * To use the model you must initialize the {@link #lightCapabilities} during initialization as follows: + *

+ * Also set {@link #rgbDataType} to the chosen RGB data type RGB, RGBW, RGBCW etc. + * And optionally set the following configuration parameters: + * + *

+ * The model specifically handles the following "exotic" cases: + *

    + *
  1. It handles inter relationships between the brightness PercentType state, the 'B' part of the HSBType state, and + * the OnOffType state. Where if the brightness goes below the configured {@link #minimumOnBrightness} level the on/off + * state changes from ON to OFF, and the brightness is clamped to 0%. And analogously if the on/off state changes from + * OFF to ON, the brightness changes from 0% to its last non zero value.
  2. + *
  3. It handles IncreaseDecreaseType commands to change the brightness up or down by the configured + * {@link #stepSize}, and ensures that the brightness is clamped in the range [0%..100%].
  4. + *
  5. It handles both color temperature PercentType states and QuantityType states (which may be either in Mirek/Mired + * or Kelvin). Where color temperature PercentType values are internally converted to Mirek/Mired values on the + * percentage scale between the configured {@link #mirekControlCoolest} and {@link #mirekControlWarmest} Mirek/Mired + * values, and vice versa.
  6. + *
  7. When the color temperature changes then the HS values are adapted to match the corresponding color temperature + * point on the Planckian Locus in the CIE color chart.
  8. + *
  9. It handles input/output values in RGB format in the range [0..255]. The behavior depends on the + * {@link #rgbDataType} setting. If {@link #rgbDataType} is DEFAULT the RGB values read/write all three parts of the + * HSBType state. Whereas if it is {@link #rgbDataType} is RGB_NO_BRIGHTNESS the RGB values read/write only + * the 'HS' parts. NOTE: in the latter case, a 'setRGBx()' call followed by a 'getRGBx()' call do not necessarily return + * the same values, since the values are normalized to 100%. Nevertheless the ratios between the RGB values do remain + * unchanged.
  10. + *
  11. If {@link #rgbDataType} is RGB_W it handles values in RGBW format. The behavior is similar to the RGB case above + * except that the white channel is derived from the lowest of the RGB values.
  12. + *
  13. If {@link #rgbDataType} is RGB_C_W it handles values in RGBCW format. The behavior is similar to the RGBW case + * above except that the white channel is derived from the RGB values by a custom algorithm.
  14. + *
+ *

+ * 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: + *

+ */ + public LightModel() { + this(LightCapabilities.COLOR_WITH_COLOR_TEMPERATURE, RgbDataType.DEFAULT, null, null, null, null, null, null); + } + + /** + * Create a {@link LightModel} with the given capabilities. The parameters are set to the default. + * + * @param lightCapabilities the capabilities of the light + * @param rgbDataType the type of RGB data used + */ + public LightModel(LightCapabilities lightCapabilities, RgbDataType rgbDataType) { + this(lightCapabilities, rgbDataType, null, null, null, null, null, null); + } + + /** + * Create a {@link LightModel} with the given capabilities and parameters. The parameters can be + * null to use the default. + * + * @param lightCapabilities the capabilities of the light + * @param rgbDataType the type of RGB data supported + * @param minimumOnBrightness the minimum brightness percent to consider as light "ON" + * @param mirekControlCoolest the 'coolest' white color temperature control value in Mirek/Mired + * @param mirekControlWarmest the 'warmest' white color temperature control value in Mirek/Mired + * @param stepSize the step size for IncreaseDecreaseType commands + * @param coolWhiteLedMirek the color temperature of the cool white LED + * @param warmWhiteLedMirek the color temperature of the warm white LED + * @throws IllegalArgumentException if any of the parameters are out of range + */ + public LightModel(LightCapabilities lightCapabilities, RgbDataType rgbDataType, + @Nullable Double minimumOnBrightness, @Nullable Double mirekControlCoolest, + @Nullable Double mirekControlWarmest, @Nullable Double stepSize, @Nullable Double coolWhiteLedMirek, + @Nullable Double warmWhiteLedMirek) throws IllegalArgumentException { + configSetLightCapabilities(lightCapabilities); + configSetRgbDataType(rgbDataType); + if (minimumOnBrightness != null) { + configSetMinimumOnBrightness(minimumOnBrightness); + } + if (mirekControlWarmest != null) { + configSetMirekControlWarmest(mirekControlWarmest); + } + if (mirekControlCoolest != null) { + configSetMirekControlCoolest(mirekControlCoolest); + } + if (stepSize != null) { + configSetIncreaseDecreaseStep(stepSize); + } + if (coolWhiteLedMirek != null) { + configSetMirekCoolWhiteLED(coolWhiteLedMirek); + } + if (warmWhiteLedMirek != null) { + configSetMirekWarmWhiteLED(warmWhiteLedMirek); + } + } + + /* + * ================================================================================ + * SECTION: Configuration getters and setters. May be used during initialization. + * ================================================================================ + */ + + /** + * Configuration: get the step size for IncreaseDecreaseType commands. + */ + public synchronized double configGetIncreaseDecreaseStep() { + return stepSize; + } + + /** + * Configuration: get the light capabilities. + */ + public synchronized LightCapabilities configGetLightCapabilities() { + return lightCapabilities; + } + + /** + * Configuration: get the minimum brightness percent to consider as light "ON". + */ + public synchronized double configGetMinimumOnBrightness() { + return minimumOnBrightness; + } + + /** + * Configuration: get the coolest color temperature in Mirek/Mired. + */ + public synchronized double configGetMirekControlCoolest() { + return mirekControlCoolest; + } + + /** + * Configuration: get the color temperature of the cool white LED in Mirek/Mired. + * + * @return the color temperature of the cool white LED. + */ + public synchronized double configGetMirekCoolWhiteLed() { + return coolWhiteLed.getMirek(); + } + + /** + * Configuration: get the warmest color temperature in Mirek/Mired. + */ + public synchronized double configGetMirekControlWarmest() { + return mirekControlWarmest; + } + + /** + * Configuration: get the color temperature of the warm white LED in Mirek/Mired. + * + * @return the color temperature of the warm white LED. + */ + public synchronized double configGetMirekWarmWhiteLed() { + return warmWhiteLed.getMirek(); + } + + /** + * Configuration: get the supported RGB data type. + */ + public synchronized RgbDataType configGetRgbDataType() { + return rgbDataType; + } + + /** + * Configuration: set the step size for IncreaseDecreaseType commands. + * + * @param stepSize the step size in percent. + * @throws IllegalArgumentException if the stepSize parameter is out of range. + */ + public synchronized void configSetIncreaseDecreaseStep(double stepSize) throws IllegalArgumentException { + if (stepSize < 1.0 || stepSize > 50.0) { + throw new IllegalArgumentException("Step size '%.1f' out of range [1.0..50.0]".formatted(stepSize)); + } + this.stepSize = stepSize; + } + + /** + * Configuration: set the light capabilities. + */ + public synchronized void configSetLightCapabilities(LightCapabilities lightCapabilities) { + this.lightCapabilities = lightCapabilities; + switch (lightCapabilities) { + case COLOR: + ledOperatingMode = LedOperatingMode.RGB_ONLY; + break; + case COLOR_WITH_COLOR_TEMPERATURE: + ledOperatingMode = LedOperatingMode.COMBINED; + break; + default: + ledOperatingMode = LedOperatingMode.WHITE_ONLY; + } + } + + /** + * Configuration: set the minimum brightness percent to consider as light "ON". + * + * @param minimumOnBrightness the minimum brightness percent. + * @throws IllegalArgumentException if the minimumBrightness parameter is out of range. + */ + public synchronized void configSetMinimumOnBrightness(double minimumOnBrightness) throws IllegalArgumentException { + if (minimumOnBrightness < 0.1 || minimumOnBrightness > 10.0) { + throw new IllegalArgumentException( + "Minimum brightness '%.1f' out of range [0.1..10.0]".formatted(minimumOnBrightness)); + } + this.minimumOnBrightness = minimumOnBrightness; + } + + /** + * Configuration: set the coolest color temperature in Mirek/Mired. + * + * @param mirekControlCoolest the coolest supported color temperature in Mirek/Mired. + * @throws IllegalArgumentException if the mirekControlCoolest parameter is out of range or not less than + * mirekControlWarmest. + */ + public synchronized void configSetMirekControlCoolest(double mirekControlCoolest) throws IllegalArgumentException { + if (mirekControlCoolest < 100.0 || mirekControlCoolest > 1000.0) { + throw new IllegalArgumentException( + "Coolest Mirek/Mired '%.1f' out of range [100.0..1000.0]".formatted(mirekControlCoolest)); + } + if (mirekControlWarmest <= mirekControlCoolest) { + throw new IllegalArgumentException("Warmest Mirek/Mired '%.1f' must be greater than the coolest '%.1f'" + .formatted(mirekControlWarmest, mirekControlCoolest)); + } + this.mirekControlCoolest = mirekControlCoolest; + } + + /** + * Configuration: set the warmest color temperature in Mirek/Mired. + * + * @param mirekControlWarmest the warmest supported color temperature in Mirek/Mired. + * @throws IllegalArgumentException if the mirekControlWarmest parameter is out of range or not greater than + * mirekControlCoolest. + */ + public synchronized void configSetMirekControlWarmest(double mirekControlWarmest) throws IllegalArgumentException { + if (mirekControlWarmest < 100.0 || mirekControlWarmest > 1000.0) { + throw new IllegalArgumentException( + "Warmest Mirek/Mired '%.1f' out of range [100.0..1000.0]".formatted(mirekControlWarmest)); + } + if (mirekControlWarmest <= mirekControlCoolest) { + throw new IllegalArgumentException("Warmest Mirek/Mired '%.1f' must be greater than coolest '%.1f'" + .formatted(mirekControlWarmest, mirekControlCoolest)); + } + this.mirekControlWarmest = mirekControlWarmest; + } + + /** + * Configuration: set the color temperature of the cool 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 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: + * + *