2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
14 package org.openhab.binding.mqtt.espmilighthub.internal.handler;
16 import static org.openhab.binding.mqtt.MqttBindingConstants.BINDING_ID;
17 import static org.openhab.binding.mqtt.espmilighthub.internal.EspMilightHubBindingConstants.*;
19 import java.math.BigDecimal;
20 import java.math.MathContext;
21 import java.nio.charset.StandardCharsets;
23 import org.eclipse.jdt.annotation.NonNullByDefault;
24 import org.eclipse.jdt.annotation.Nullable;
25 import org.openhab.binding.mqtt.espmilighthub.internal.ConfigOptions;
26 import org.openhab.binding.mqtt.espmilighthub.internal.Helper;
27 import org.openhab.binding.mqtt.handler.AbstractBrokerHandler;
28 import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
29 import org.openhab.core.io.transport.mqtt.MqttConnectionObserver;
30 import org.openhab.core.io.transport.mqtt.MqttConnectionState;
31 import org.openhab.core.io.transport.mqtt.MqttMessageSubscriber;
32 import org.openhab.core.library.types.DecimalType;
33 import org.openhab.core.library.types.HSBType;
34 import org.openhab.core.library.types.IncreaseDecreaseType;
35 import org.openhab.core.library.types.OnOffType;
36 import org.openhab.core.library.types.PercentType;
37 import org.openhab.core.library.types.StringType;
38 import org.openhab.core.thing.Bridge;
39 import org.openhab.core.thing.ChannelUID;
40 import org.openhab.core.thing.Thing;
41 import org.openhab.core.thing.ThingRegistry;
42 import org.openhab.core.thing.ThingStatus;
43 import org.openhab.core.thing.ThingStatusDetail;
44 import org.openhab.core.thing.ThingUID;
45 import org.openhab.core.thing.binding.BaseThingHandler;
46 import org.openhab.core.thing.binding.ThingHandler;
47 import org.openhab.core.types.Command;
48 import org.openhab.core.types.RefreshType;
49 import org.openhab.core.types.State;
50 import org.slf4j.Logger;
51 import org.slf4j.LoggerFactory;
54 * The {@link EspMilightHubHandler} is responsible for handling commands of the globes, which are then
55 * sent to one of the bridges to be sent out by MQTT.
57 * @author Matthew Skinner - Initial contribution
60 public class EspMilightHubHandler extends BaseThingHandler implements MqttConnectionObserver, MqttMessageSubscriber {
61 // these are all constants used in color conversion calcuations.
62 // strings are necessary to prevent floating point loss of precision
63 private static final BigDecimal BIG_DECIMAL_THOUSAND = new BigDecimal(1000);
64 private static final BigDecimal BIG_DECIMAL_MILLION = new BigDecimal(1000000);
66 private static final BigDecimal[][] KANG_X_COEFFICIENTS = {
67 { new BigDecimal("-3.0258469"), new BigDecimal("2.1070379"), new BigDecimal("0.2226347"),
68 new BigDecimal("0.24039") },
69 { new BigDecimal("-0.2661239"), new BigDecimal("-0.234589"), new BigDecimal("0.8776956"),
70 new BigDecimal("0.179910") } };
72 private static final BigDecimal[][] KANG_Y_COEFFICIENTS = {
73 { new BigDecimal("3.0817580"), new BigDecimal("-5.8733867"), new BigDecimal("3.75112997"),
74 new BigDecimal("-0.37001483") },
75 { new BigDecimal("-0.9549476"), new BigDecimal("-1.37418593"), new BigDecimal("2.09137015"),
76 new BigDecimal("-0.16748867") },
77 { new BigDecimal("-1.1063814"), new BigDecimal("-1.34811020"), new BigDecimal("2.18555832"),
78 new BigDecimal("-0.20219683") } };
80 private static final BigDecimal BIG_DECIMAL_03320 = new BigDecimal("0.3320");
81 private static final BigDecimal BIG_DECIMAL_01858 = new BigDecimal("0.1858");
82 private static final BigDecimal[] MCCAMY_COEFFICIENTS = { new BigDecimal(437), new BigDecimal(3601),
83 new BigDecimal(6862), new BigDecimal(5517) };
85 private static final BigDecimal BIG_DECIMAL_2 = new BigDecimal(2);
86 private static final BigDecimal BIG_DECIMAL_3 = new BigDecimal(3);
87 private static final BigDecimal BIG_DECIMAL_4 = new BigDecimal(4);
88 private static final BigDecimal BIG_DECIMAL_6 = new BigDecimal(6);
89 private static final BigDecimal BIG_DECIMAL_12 = new BigDecimal(12);
91 private static final BigDecimal BIG_DECIMAL_0292 = new BigDecimal("0.292");
92 private static final BigDecimal BIG_DECIMAL_024 = new BigDecimal("0.24");
94 private static final BigDecimal[] CORM_COEFFICIENTS = { new BigDecimal("-0.00616793"), new BigDecimal("0.0893944"),
95 new BigDecimal("-0.5179722"), new BigDecimal("1.5317403"), new BigDecimal("-2.4243787"),
96 new BigDecimal("1.925865"), new BigDecimal("-0.471106") };
98 private static final BigDecimal BIG_DECIMAL_153 = new BigDecimal(153);
99 private static final BigDecimal BIG_DECIMAL_217 = new BigDecimal(217);
101 private final Logger logger = LoggerFactory.getLogger(this.getClass());
102 private @Nullable MqttBrokerConnection connection;
103 private ThingRegistry thingRegistry;
104 private String globeType = "";
105 private String bulbMode = "";
106 private String remotesGroupID = "";
107 private String channelPrefix = "";
108 private String fullCommandTopic = "";
109 private String fullStatesTopic = "";
110 private BigDecimal maxColourTemp = BigDecimal.ZERO;
111 private BigDecimal minColourTemp = BigDecimal.ZERO;
112 private BigDecimal savedLevel = BIG_DECIMAL_100;
113 private ConfigOptions config = new ConfigOptions();
115 public EspMilightHubHandler(Thing thing, ThingRegistry thingRegistry) {
117 this.thingRegistry = thingRegistry;
120 void changeChannel(String channel, State state) {
121 updateState(new ChannelUID(channelPrefix + channel), state);
122 // Remote code of 0 means that all channels need to follow this change.
123 if ("0".equals(remotesGroupID)) {
125 // These two are 8 channel remotes
128 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "5:" + channel),
130 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "6:" + channel),
132 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "7:" + channel),
134 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "8:" + channel),
137 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "1:" + channel),
139 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "2:" + channel),
141 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "3:" + channel),
143 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "4:" + channel),
149 private void processIncomingState(String messageJSON) {
150 // Need to handle State and Level at the same time to process level=0 as off//
151 PercentType tempBulbLevel = PercentType.ZERO;
152 String bulbState = Helper.resolveJSON(messageJSON, "\"state\":\"", 3);
153 String bulbLevel = Helper.resolveJSON(messageJSON, "\"level\":", 3);
154 if (!bulbLevel.isEmpty()) {
155 if ("0".equals(bulbLevel) || "OFF".equals(bulbState)) {
156 changeChannel(CHANNEL_LEVEL, OnOffType.OFF);
158 tempBulbLevel = new PercentType(Integer.valueOf(bulbLevel));
159 changeChannel(CHANNEL_LEVEL, tempBulbLevel);
161 } else if ("ON".equals(bulbState) || "OFF".equals(bulbState)) { // NOTE: Level is missing when this runs
162 changeChannel(CHANNEL_LEVEL, OnOffType.valueOf(bulbState));
164 bulbMode = Helper.resolveJSON(messageJSON, "\"bulb_mode\":\"", 5);
168 changeChannel(CHANNEL_BULB_MODE, new StringType("white"));
169 changeChannel(CHANNEL_DISCO_MODE, new StringType("None"));
171 String bulbCTempS = Helper.resolveJSON(messageJSON, "\"color_temp\":", 3);
172 if (!bulbCTempS.isEmpty()) {
173 var bulbCTemp = Integer.valueOf(bulbCTempS);
174 changeChannel(CHANNEL_COLOURTEMP, scaleMireds(bulbCTemp));
176 changeChannel(CHANNEL_COLOUR, calculateHSBFromColorTemp(bulbCTemp, tempBulbLevel));
181 changeChannel(CHANNEL_BULB_MODE, new StringType("color"));
182 changeChannel(CHANNEL_DISCO_MODE, new StringType("None"));
183 String bulbHue = Helper.resolveJSON(messageJSON, "\"hue\":", 3);
184 String bulbSaturation = Helper.resolveJSON(messageJSON, "\"saturation\":", 3);
185 if (bulbHue.isEmpty()) {
186 logger.warn("Milight MQTT message came in as being a colour mode, but was missing a HUE value.");
188 if (bulbSaturation.isEmpty()) {
189 bulbSaturation = "100";
191 // 360 isn't allowed by OpenHAB
192 if (bulbHue.equals("360")) {
195 var hsb = new HSBType(new DecimalType(Integer.valueOf(bulbHue)),
196 new PercentType(Integer.valueOf(bulbSaturation)), tempBulbLevel);
197 changeChannel(CHANNEL_COLOUR, hsb);
199 changeChannel(CHANNEL_COLOURTEMP, scaleMireds(calculateColorTempFromHSB(hsb)));
205 changeChannel(CHANNEL_BULB_MODE, new StringType("scene"));
207 String bulbDiscoMode = Helper.resolveJSON(messageJSON, "\"mode\":", 1);
208 if (!bulbDiscoMode.isEmpty()) {
209 changeChannel(CHANNEL_DISCO_MODE, new StringType(bulbDiscoMode));
214 changeChannel(CHANNEL_BULB_MODE, new StringType("night"));
215 if (config.oneTriggersNightMode) {
216 changeChannel(CHANNEL_LEVEL, new PercentType("1"));
223 private boolean hasCCT() {
235 private boolean hasRGB() {
248 * Scales mireds to 0-100%
250 private static PercentType scaleMireds(int mireds) {
251 // range in mireds is 153-370
252 // 100 - (mireds - 153) / (370 - 153) * 100
254 return PercentType.HUNDRED;
255 } else if (mireds <= 153) {
256 return PercentType.ZERO;
258 return new PercentType(BIG_DECIMAL_100.subtract(new BigDecimal(mireds).subtract(BIG_DECIMAL_153)
259 .divide(BIG_DECIMAL_217, MathContext.DECIMAL128).multiply(BIG_DECIMAL_100)));
262 private static BigDecimal polynomialFit(BigDecimal x, BigDecimal[] coefficients) {
263 var result = BigDecimal.ZERO;
264 var xAccumulator = BigDecimal.ONE;
265 // forms K[4]*x^0 + K[3]*x^1 + K[2]*x^2 + K[1]*x^3 + K[0]*x^4
266 // (or reverse the order of terms for the usual way of writing it in academic papers)
267 for (int i = coefficients.length - 1; i >= 0; i--) {
268 result = result.add(coefficients[i].multiply(xAccumulator));
269 xAccumulator = xAccumulator.multiply(x);
274 // https://www.jkps.or.kr/journal/download_pdf.php?spage=865&volume=41&number=6 (8) and (9)
275 private static HSBType calculateHSBFromColorTemp(int mireds, PercentType brightness) {
276 var cct = BIG_DECIMAL_MILLION.divide(new BigDecimal(mireds), MathContext.DECIMAL128);
277 var cctInt = cct.intValue();
279 BigDecimal[] coefficients;
280 // 1667K to 4000K and 4000K to 25000K; no range checks since our mired range fits within this
281 if (cctInt <= 4000) {
282 coefficients = KANG_X_COEFFICIENTS[1];
284 coefficients = KANG_X_COEFFICIENTS[0];
286 BigDecimal x = polynomialFit(BIG_DECIMAL_THOUSAND.divide(cct, MathContext.DECIMAL128), coefficients);
288 if (cctInt <= 2222) {
289 coefficients = KANG_Y_COEFFICIENTS[2];
290 } else if (cctInt <= 4000) {
291 coefficients = KANG_Y_COEFFICIENTS[1];
293 coefficients = KANG_Y_COEFFICIENTS[0];
295 BigDecimal y = polynomialFit(x, coefficients);
296 var rawHsb = HSBType.fromXY(x.floatValue() * 100.0f, y.floatValue() * 100.0f);
297 return new HSBType(rawHsb.getHue(), rawHsb.getSaturation(), brightness);
300 // https://www.waveformlighting.com/tech/calculate-color-temperature-cct-from-cie-1931-xy-coordinates/
301 private static int calculateColorTempFromHSB(HSBType hsb) {
302 PercentType[] xy = hsb.toXY();
303 var x = xy[0].toBigDecimal().divide(BIG_DECIMAL_100);
304 var y = xy[1].toBigDecimal().divide(BIG_DECIMAL_100);
305 var n = x.subtract(BIG_DECIMAL_03320).divide(BIG_DECIMAL_01858.subtract(y), MathContext.DECIMAL128);
306 BigDecimal cctK = polynomialFit(n, MCCAMY_COEFFICIENTS);
307 return BIG_DECIMAL_MILLION.divide(cctK, MathContext.DECIMAL128).round(new MathContext(0)).intValue();
310 // https://cormusa.org/wp-content/uploads/2018/04/CORM_2011_Calculation_of_CCT_and_Duv_and_Practical_Conversion_Formulae.pdf
312 private static BigDecimal calculateDuvFromHSB(HSBType hsb) {
313 PercentType[] xy = hsb.toXY();
314 var x = xy[0].toBigDecimal().divide(BIG_DECIMAL_100);
315 var y = xy[1].toBigDecimal().divide(BIG_DECIMAL_100);
316 var u = BIG_DECIMAL_4.multiply(x).divide(
317 BIG_DECIMAL_2.multiply(x).negate().add(BIG_DECIMAL_12.multiply(y).add(BIG_DECIMAL_3)),
318 MathContext.DECIMAL128);
319 var v = BIG_DECIMAL_6.multiply(y).divide(
320 BIG_DECIMAL_2.multiply(x).negate().add(BIG_DECIMAL_12.multiply(y).add(BIG_DECIMAL_3)),
321 MathContext.DECIMAL128);
322 var Lfp = u.subtract(BIG_DECIMAL_0292).pow(2).add(v.subtract(BIG_DECIMAL_024).pow(2))
323 .sqrt(MathContext.DECIMAL128);
324 var a = new BigDecimal(
325 Math.acos(u.subtract(BIG_DECIMAL_0292).divide(Lfp, MathContext.DECIMAL128).doubleValue()));
326 BigDecimal Lbb = polynomialFit(a, CORM_COEFFICIENTS);
327 return Lfp.subtract(Lbb);
331 * Used to calculate the colour temp for a globe if you want the light to get warmer as it is dimmed like a
332 * traditional halogen globe
334 private int autoColourTemp(int brightness) {
335 return minColourTemp.subtract(
336 minColourTemp.subtract(maxColourTemp).divide(BIG_DECIMAL_100).multiply(new BigDecimal(brightness)))
341 if (config.powerFailsToMinimum) {
342 sendMQTT("{\"state\":\"OFF\",\"level\":0}");
344 sendMQTT("{\"state\":\"OFF\"}");
348 void handleLevelColour(Command command) {
351 if (command instanceof OnOffType) {
352 if (OnOffType.ON.equals(command)) {
353 sendMQTT("{\"state\":\"ON\",\"level\":" + savedLevel + "}");
358 } else if (command instanceof IncreaseDecreaseType) {
359 if (IncreaseDecreaseType.INCREASE.equals(command)) {
360 if (savedLevel.intValue() <= 90) {
361 savedLevel = savedLevel.add(BigDecimal.TEN);
364 if (savedLevel.intValue() >= 10) {
365 savedLevel = savedLevel.subtract(BigDecimal.TEN);
368 sendMQTT("{\"state\":\"ON\",\"level\":" + savedLevel.intValue() + "}");
370 } else if (command instanceof HSBType) {
371 HSBType hsb = (HSBType) command;
372 // This feature allows google home or Echo to trigger white mode when asked to turn color to white.
373 if (hsb.getHue().intValue() == config.whiteHue && hsb.getSaturation().intValue() == config.whiteSat) {
375 sendMQTT("{\"state\":\"ON\",\"color_temp\":" + config.favouriteWhite + "}");
376 } else {// globe must only have 1 type of white
377 sendMQTT("{\"command\":\"set_white\"}");
380 } else if (PercentType.ZERO.equals(hsb.getBrightness())) {
383 } else if (config.duvThreshold.compareTo(BigDecimal.ONE) < 0
384 && calculateDuvFromHSB(hsb).abs().compareTo(config.duvThreshold) <= 0
385 && (mireds = calculateColorTempFromHSB(hsb)) >= 153 && mireds <= 370) {
386 sendMQTT("{\"state\":\"ON\",\"level\":" + hsb.getBrightness() + ",\"color_temp\":" + mireds + "}");
387 } else if (config.whiteThreshold != -1 && hsb.getSaturation().intValue() <= config.whiteThreshold) {
388 sendMQTT("{\"command\":\"set_white\"}");// Can't send the command and level in the same message.
389 sendMQTT("{\"level\":" + hsb.getBrightness().intValue() + "}");
391 sendMQTT("{\"state\":\"ON\",\"level\":" + hsb.getBrightness().intValue() + ",\"hue\":"
392 + hsb.getHue().intValue() + ",\"saturation\":" + hsb.getSaturation().intValue() + "}");
394 savedLevel = hsb.getBrightness().toBigDecimal();
396 } else if (command instanceof PercentType) {
397 PercentType percentType = (PercentType) command;
398 if (percentType.intValue() == 0) {
401 } else if (percentType.intValue() == 1 && config.oneTriggersNightMode) {
402 sendMQTT("{\"command\":\"night_mode\"}");
405 sendMQTT("{\"state\":\"ON\",\"level\":" + command + "}");
406 savedLevel = percentType.toBigDecimal();
408 if (config.dimmedCT > 0 && "white".equals(bulbMode)) {
409 sendMQTT("{\"state\":\"ON\",\"color_temp\":" + autoColourTemp(savedLevel.intValue()) + "}");
417 public void handleCommand(ChannelUID channelUID, Command command) {
418 if (command instanceof RefreshType) {
421 switch (channelUID.getId()) {
424 handleLevelColour(command);
426 case CHANNEL_BULB_MODE:
427 bulbMode = command.toString();
429 case CHANNEL_COLOURTEMP:
430 int scaledCommand = (int) Math.round((370 - (2.17 * Float.valueOf(command.toString()))));
431 sendMQTT("{\"state\":\"ON\",\"level\":" + savedLevel + ",\"color_temp\":" + scaledCommand + "}");
433 case CHANNEL_COMMAND:
434 sendMQTT("{\"command\":\"" + command + "\"}");
436 case CHANNEL_DISCO_MODE:
437 sendMQTT("{\"mode\":\"" + command + "\"}");
443 public void initialize() {
444 config = getConfigAs(ConfigOptions.class);
445 if (config.dimmedCT > 0) {
446 maxColourTemp = new BigDecimal(config.favouriteWhite);
447 minColourTemp = new BigDecimal(config.dimmedCT);
448 if (minColourTemp.intValue() <= maxColourTemp.intValue()) {
449 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
450 "The dimmedCT config value must be greater than the favourite White value.");
454 Bridge localBridge = getBridge();
455 if (localBridge == null) {
456 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_PENDING,
457 "Globe must have a valid bridge selected before it can come online.");
460 globeType = thing.getThingTypeUID().getId();// eg rgb_cct
461 String globeLocation = this.getThing().getUID().getId();// eg 0x014
462 remotesGroupID = globeLocation.substring(globeLocation.length() - 1, globeLocation.length());// eg 4
463 String remotesIDCode = globeLocation.substring(0, globeLocation.length() - 1);// eg 0x01
464 fullCommandTopic = COMMANDS_BASE_TOPIC + remotesIDCode + "/" + globeType + "/" + remotesGroupID;
465 fullStatesTopic = STATES_BASE_TOPIC + remotesIDCode + "/" + globeType + "/" + remotesGroupID;
466 // Need to remove the lowercase x from 0x12AB in case it contains all numbers
467 String caseCheck = globeLocation.substring(2, globeLocation.length() - 1);
468 if (!caseCheck.equals(caseCheck.toUpperCase())) {
470 "The milight globe {}{} is using lowercase for the remote code when the hub needs UPPERCASE",
471 remotesIDCode, remotesGroupID);
473 channelPrefix = BINDING_ID + ":" + globeType + ":" + localBridge.getUID().getId() + ":" + remotesIDCode
474 + remotesGroupID + ":";
479 private void sendMQTT(String payload) {
480 MqttBrokerConnection localConnection = connection;
481 if (localConnection != null) {
482 localConnection.publish(fullCommandTopic, payload.getBytes(), 1, false);
487 public void processMessage(String topic, byte[] payload) {
488 String state = new String(payload, StandardCharsets.UTF_8);
489 logger.trace("Received the following new Milight state:{}:{}", topic, state);
491 processIncomingState(state);
492 } catch (Exception e) {
493 logger.warn("Failed processing Milight state {} for {}", state, topic, e);
498 public void connectionStateChanged(MqttConnectionState state, @Nullable Throwable error) {
499 logger.debug("MQTT brokers state changed to:{}", state);
502 updateStatus(ThingStatus.ONLINE);
506 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
507 "Bridge (broker) is not connected to your MQTT broker.");
511 public void connectMQTT() {
512 Bridge localBridge = this.getBridge();
513 if (localBridge == null) {
514 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED,
515 "Bridge is missing or offline, you need to setup a working MQTT broker first.");
518 ThingUID thingUID = localBridge.getUID();
519 Thing thing = thingRegistry.get(thingUID);
521 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED,
522 "Bridge is missing or offline, you need to setup a working MQTT broker first.");
525 ThingHandler handler = thing.getHandler();
526 if (handler instanceof AbstractBrokerHandler) {
527 AbstractBrokerHandler abh = (AbstractBrokerHandler) handler;
528 MqttBrokerConnection localConnection = abh.getConnection();
529 if (localConnection != null) {
530 localConnection.setKeepAliveInterval(20);
531 localConnection.setQos(1);
532 localConnection.setUnsubscribeOnStop(true);
533 localConnection.addConnectionObserver(this);
534 localConnection.start();
535 localConnection.subscribe(fullStatesTopic + "/#", this);
536 connection = localConnection;
537 if (localConnection.connectionState().compareTo(MqttConnectionState.CONNECTED) == 0) {
538 updateStatus(ThingStatus.ONLINE);
546 public void dispose() {
547 MqttBrokerConnection localConnection = connection;
548 if (localConnection != null) {
549 localConnection.unsubscribe(fullStatesTopic + "/#", this);