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;
22 import java.util.concurrent.ExecutionException;
23 import java.util.concurrent.TimeUnit;
24 import java.util.concurrent.TimeoutException;
26 import org.eclipse.jdt.annotation.NonNullByDefault;
27 import org.eclipse.jdt.annotation.Nullable;
28 import org.openhab.binding.mqtt.espmilighthub.internal.ConfigOptions;
29 import org.openhab.binding.mqtt.espmilighthub.internal.Helper;
30 import org.openhab.binding.mqtt.handler.AbstractBrokerHandler;
31 import org.openhab.core.io.transport.mqtt.MqttBrokerConnection;
32 import org.openhab.core.io.transport.mqtt.MqttMessageSubscriber;
33 import org.openhab.core.library.types.DecimalType;
34 import org.openhab.core.library.types.HSBType;
35 import org.openhab.core.library.types.IncreaseDecreaseType;
36 import org.openhab.core.library.types.OnOffType;
37 import org.openhab.core.library.types.PercentType;
38 import org.openhab.core.library.types.StringType;
39 import org.openhab.core.thing.Bridge;
40 import org.openhab.core.thing.ChannelUID;
41 import org.openhab.core.thing.Thing;
42 import org.openhab.core.thing.ThingRegistry;
43 import org.openhab.core.thing.ThingStatus;
44 import org.openhab.core.thing.ThingStatusDetail;
45 import org.openhab.core.thing.ThingStatusInfo;
46 import org.openhab.core.thing.binding.BaseThingHandler;
47 import org.openhab.core.thing.binding.ThingHandler;
48 import org.openhab.core.types.Command;
49 import org.openhab.core.types.RefreshType;
50 import org.openhab.core.types.State;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
55 * The {@link EspMilightHubHandler} is responsible for handling commands of the globes, which are then
56 * sent to one of the bridges to be sent out by MQTT.
58 * @author Matthew Skinner - Initial contribution
61 public class EspMilightHubHandler extends BaseThingHandler implements MqttMessageSubscriber {
62 // these are all constants used in color conversion calcuations.
63 // strings are necessary to prevent floating point loss of precision
64 private static final BigDecimal BIG_DECIMAL_THOUSAND = new BigDecimal(1000);
65 private static final BigDecimal BIG_DECIMAL_MILLION = new BigDecimal(1000000);
67 private static final BigDecimal[][] KANG_X_COEFFICIENTS = {
68 { new BigDecimal("-3.0258469"), new BigDecimal("2.1070379"), new BigDecimal("0.2226347"),
69 new BigDecimal("0.24039") },
70 { new BigDecimal("-0.2661239"), new BigDecimal("-0.234589"), new BigDecimal("0.8776956"),
71 new BigDecimal("0.179910") } };
73 private static final BigDecimal[][] KANG_Y_COEFFICIENTS = {
74 { new BigDecimal("3.0817580"), new BigDecimal("-5.8733867"), new BigDecimal("3.75112997"),
75 new BigDecimal("-0.37001483") },
76 { new BigDecimal("-0.9549476"), new BigDecimal("-1.37418593"), new BigDecimal("2.09137015"),
77 new BigDecimal("-0.16748867") },
78 { new BigDecimal("-1.1063814"), new BigDecimal("-1.34811020"), new BigDecimal("2.18555832"),
79 new BigDecimal("-0.20219683") } };
81 private static final BigDecimal BIG_DECIMAL_03320 = new BigDecimal("0.3320");
82 private static final BigDecimal BIG_DECIMAL_01858 = new BigDecimal("0.1858");
83 private static final BigDecimal[] MCCAMY_COEFFICIENTS = { new BigDecimal(437), new BigDecimal(3601),
84 new BigDecimal(6862), new BigDecimal(5517) };
86 private static final BigDecimal BIG_DECIMAL_2 = new BigDecimal(2);
87 private static final BigDecimal BIG_DECIMAL_3 = new BigDecimal(3);
88 private static final BigDecimal BIG_DECIMAL_4 = new BigDecimal(4);
89 private static final BigDecimal BIG_DECIMAL_6 = new BigDecimal(6);
90 private static final BigDecimal BIG_DECIMAL_12 = new BigDecimal(12);
92 private static final BigDecimal BIG_DECIMAL_0292 = new BigDecimal("0.292");
93 private static final BigDecimal BIG_DECIMAL_024 = new BigDecimal("0.24");
95 private static final BigDecimal[] CORM_COEFFICIENTS = { new BigDecimal("-0.00616793"), new BigDecimal("0.0893944"),
96 new BigDecimal("-0.5179722"), new BigDecimal("1.5317403"), new BigDecimal("-2.4243787"),
97 new BigDecimal("1.925865"), new BigDecimal("-0.471106") };
99 private static final BigDecimal BIG_DECIMAL_153 = new BigDecimal(153);
100 private static final BigDecimal BIG_DECIMAL_217 = new BigDecimal(217);
102 private final Logger logger = LoggerFactory.getLogger(this.getClass());
103 private @Nullable MqttBrokerConnection connection;
104 private ThingRegistry thingRegistry;
105 private String globeType = "";
106 private String bulbMode = "";
107 private String remotesGroupID = "";
108 private String channelPrefix = "";
109 private String fullCommandTopic = "";
110 private String fullStatesTopic = "";
111 private BigDecimal maxColourTemp = BigDecimal.ZERO;
112 private BigDecimal minColourTemp = BigDecimal.ZERO;
113 private BigDecimal savedLevel = BIG_DECIMAL_100;
114 private ConfigOptions config = new ConfigOptions();
116 public EspMilightHubHandler(Thing thing, ThingRegistry thingRegistry) {
118 this.thingRegistry = thingRegistry;
121 void changeChannel(String channel, State state) {
122 updateState(new ChannelUID(channelPrefix + channel), state);
123 // Remote code of 0 means that all channels need to follow this change.
124 if ("0".equals(remotesGroupID)) {
126 // These two are 8 channel remotes
129 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "5:" + channel),
131 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "6:" + channel),
133 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "7:" + channel),
135 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "8:" + channel),
138 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "1:" + channel),
140 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "2:" + channel),
142 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "3:" + channel),
144 updateState(new ChannelUID(channelPrefix.substring(0, channelPrefix.length() - 2) + "4:" + channel),
150 private void processIncomingState(String messageJSON) {
151 // Need to handle State and Level at the same time to process level=0 as off//
152 PercentType tempBulbLevel = PercentType.ZERO;
153 String bulbState = Helper.resolveJSON(messageJSON, "\"state\":\"", 3);
154 String bulbLevel = Helper.resolveJSON(messageJSON, "\"level\":", 3);
155 if (!bulbLevel.isEmpty()) {
156 if ("0".equals(bulbLevel) || "OFF".equals(bulbState)) {
157 changeChannel(CHANNEL_LEVEL, OnOffType.OFF);
159 tempBulbLevel = new PercentType(Integer.valueOf(bulbLevel));
160 changeChannel(CHANNEL_LEVEL, tempBulbLevel);
162 } else if ("ON".equals(bulbState) || "OFF".equals(bulbState)) { // NOTE: Level is missing when this runs
163 changeChannel(CHANNEL_LEVEL, OnOffType.valueOf(bulbState));
165 bulbMode = Helper.resolveJSON(messageJSON, "\"bulb_mode\":\"", 5);
169 changeChannel(CHANNEL_BULB_MODE, new StringType("white"));
170 changeChannel(CHANNEL_DISCO_MODE, new StringType("None"));
172 String bulbCTempS = Helper.resolveJSON(messageJSON, "\"color_temp\":", 3);
173 if (!bulbCTempS.isEmpty()) {
174 var bulbCTemp = Integer.valueOf(bulbCTempS);
175 changeChannel(CHANNEL_COLOURTEMP, scaleMireds(bulbCTemp));
177 changeChannel(CHANNEL_COLOUR, calculateHSBFromColorTemp(bulbCTemp, tempBulbLevel));
182 changeChannel(CHANNEL_BULB_MODE, new StringType("color"));
183 changeChannel(CHANNEL_DISCO_MODE, new StringType("None"));
184 String bulbHue = Helper.resolveJSON(messageJSON, "\"hue\":", 3);
185 String bulbSaturation = Helper.resolveJSON(messageJSON, "\"saturation\":", 3);
186 if (bulbHue.isEmpty()) {
187 logger.warn("Milight MQTT message came in as being a colour mode, but was missing a HUE value.");
189 if (bulbSaturation.isEmpty()) {
190 bulbSaturation = "100";
192 // 360 isn't allowed by OpenHAB
193 if (bulbHue.equals("360")) {
196 var hsb = new HSBType(new DecimalType(Integer.valueOf(bulbHue)),
197 new PercentType(Integer.valueOf(bulbSaturation)), tempBulbLevel);
198 changeChannel(CHANNEL_COLOUR, hsb);
200 changeChannel(CHANNEL_COLOURTEMP, scaleMireds(calculateColorTempFromHSB(hsb)));
206 changeChannel(CHANNEL_BULB_MODE, new StringType("scene"));
208 String bulbDiscoMode = Helper.resolveJSON(messageJSON, "\"mode\":", 1);
209 if (!bulbDiscoMode.isEmpty()) {
210 changeChannel(CHANNEL_DISCO_MODE, new StringType(bulbDiscoMode));
215 changeChannel(CHANNEL_BULB_MODE, new StringType("night"));
216 if (config.oneTriggersNightMode) {
217 changeChannel(CHANNEL_LEVEL, new PercentType("1"));
224 private boolean hasCCT() {
236 private boolean hasRGB() {
249 * Scales mireds to 0-100%
251 private static PercentType scaleMireds(int mireds) {
252 // range in mireds is 153-370
253 // 100 - (mireds - 153) / (370 - 153) * 100
255 return PercentType.HUNDRED;
256 } else if (mireds <= 153) {
257 return PercentType.ZERO;
259 return new PercentType(BIG_DECIMAL_100.subtract(new BigDecimal(mireds).subtract(BIG_DECIMAL_153)
260 .divide(BIG_DECIMAL_217, MathContext.DECIMAL128).multiply(BIG_DECIMAL_100)));
263 private static BigDecimal polynomialFit(BigDecimal x, BigDecimal[] coefficients) {
264 var result = BigDecimal.ZERO;
265 var xAccumulator = BigDecimal.ONE;
266 // forms K[4]*x^0 + K[3]*x^1 + K[2]*x^2 + K[1]*x^3 + K[0]*x^4
267 // (or reverse the order of terms for the usual way of writing it in academic papers)
268 for (int i = coefficients.length - 1; i >= 0; i--) {
269 result = result.add(coefficients[i].multiply(xAccumulator));
270 xAccumulator = xAccumulator.multiply(x);
275 // https://www.jkps.or.kr/journal/download_pdf.php?spage=865&volume=41&number=6 (8) and (9)
276 private static HSBType calculateHSBFromColorTemp(int mireds, PercentType brightness) {
277 var cct = BIG_DECIMAL_MILLION.divide(new BigDecimal(mireds), MathContext.DECIMAL128);
278 var cctInt = cct.intValue();
280 BigDecimal[] coefficients;
281 // 1667K to 4000K and 4000K to 25000K; no range checks since our mired range fits within this
282 if (cctInt <= 4000) {
283 coefficients = KANG_X_COEFFICIENTS[1];
285 coefficients = KANG_X_COEFFICIENTS[0];
287 BigDecimal x = polynomialFit(BIG_DECIMAL_THOUSAND.divide(cct, MathContext.DECIMAL128), coefficients);
289 if (cctInt <= 2222) {
290 coefficients = KANG_Y_COEFFICIENTS[2];
291 } else if (cctInt <= 4000) {
292 coefficients = KANG_Y_COEFFICIENTS[1];
294 coefficients = KANG_Y_COEFFICIENTS[0];
296 BigDecimal y = polynomialFit(x, coefficients);
297 var rawHsb = HSBType.fromXY(x.floatValue() * 100.0f, y.floatValue() * 100.0f);
298 return new HSBType(rawHsb.getHue(), rawHsb.getSaturation(), brightness);
301 // https://www.waveformlighting.com/tech/calculate-color-temperature-cct-from-cie-1931-xy-coordinates/
302 private static int calculateColorTempFromHSB(HSBType hsb) {
303 PercentType[] xy = hsb.toXY();
304 var x = xy[0].toBigDecimal().divide(BIG_DECIMAL_100);
305 var y = xy[1].toBigDecimal().divide(BIG_DECIMAL_100);
306 var n = x.subtract(BIG_DECIMAL_03320).divide(BIG_DECIMAL_01858.subtract(y), MathContext.DECIMAL128);
307 BigDecimal cctK = polynomialFit(n, MCCAMY_COEFFICIENTS);
308 return BIG_DECIMAL_MILLION.divide(cctK, MathContext.DECIMAL128).round(new MathContext(0)).intValue();
311 // https://cormusa.org/wp-content/uploads/2018/04/CORM_2011_Calculation_of_CCT_and_Duv_and_Practical_Conversion_Formulae.pdf
313 private static BigDecimal calculateDuvFromHSB(HSBType hsb) {
314 PercentType[] xy = hsb.toXY();
315 var x = xy[0].toBigDecimal().divide(BIG_DECIMAL_100);
316 var y = xy[1].toBigDecimal().divide(BIG_DECIMAL_100);
317 var u = BIG_DECIMAL_4.multiply(x).divide(
318 BIG_DECIMAL_2.multiply(x).negate().add(BIG_DECIMAL_12.multiply(y).add(BIG_DECIMAL_3)),
319 MathContext.DECIMAL128);
320 var v = BIG_DECIMAL_6.multiply(y).divide(
321 BIG_DECIMAL_2.multiply(x).negate().add(BIG_DECIMAL_12.multiply(y).add(BIG_DECIMAL_3)),
322 MathContext.DECIMAL128);
323 var Lfp = u.subtract(BIG_DECIMAL_0292).pow(2).add(v.subtract(BIG_DECIMAL_024).pow(2))
324 .sqrt(MathContext.DECIMAL128);
325 var a = new BigDecimal(
326 Math.acos(u.subtract(BIG_DECIMAL_0292).divide(Lfp, MathContext.DECIMAL128).doubleValue()));
327 BigDecimal Lbb = polynomialFit(a, CORM_COEFFICIENTS);
328 return Lfp.subtract(Lbb);
332 * Used to calculate the colour temp for a globe if you want the light to get warmer as it is dimmed like a
333 * traditional halogen globe
335 private int autoColourTemp(int brightness) {
336 return minColourTemp.subtract(
337 minColourTemp.subtract(maxColourTemp).divide(BIG_DECIMAL_100).multiply(new BigDecimal(brightness)))
342 if (config.powerFailsToMinimum) {
343 sendMQTT("{\"state\":\"OFF\",\"level\":0}");
345 sendMQTT("{\"state\":\"OFF\"}");
349 void handleLevelColour(Command command) {
352 if (command instanceof OnOffType) {
353 if (OnOffType.ON.equals(command)) {
354 sendMQTT("{\"state\":\"ON\",\"level\":" + savedLevel + "}");
359 } else if (command instanceof IncreaseDecreaseType) {
360 if (IncreaseDecreaseType.INCREASE.equals(command)) {
361 if (savedLevel.intValue() <= 90) {
362 savedLevel = savedLevel.add(BigDecimal.TEN);
365 if (savedLevel.intValue() >= 10) {
366 savedLevel = savedLevel.subtract(BigDecimal.TEN);
369 sendMQTT("{\"state\":\"ON\",\"level\":" + savedLevel.intValue() + "}");
371 } else if (command instanceof HSBType) {
372 HSBType hsb = (HSBType) command;
373 // This feature allows google home or Echo to trigger white mode when asked to turn color to white.
374 if (hsb.getHue().intValue() == config.whiteHue && hsb.getSaturation().intValue() == config.whiteSat) {
376 sendMQTT("{\"state\":\"ON\",\"color_temp\":" + config.favouriteWhite + "}");
377 } else {// globe must only have 1 type of white
378 sendMQTT("{\"command\":\"set_white\"}");
381 } else if (PercentType.ZERO.equals(hsb.getBrightness())) {
384 } else if (config.duvThreshold.compareTo(BigDecimal.ONE) < 0
385 && calculateDuvFromHSB(hsb).abs().compareTo(config.duvThreshold) <= 0
386 && (mireds = calculateColorTempFromHSB(hsb)) >= 153 && mireds <= 370) {
387 sendMQTT("{\"state\":\"ON\",\"level\":" + hsb.getBrightness() + ",\"color_temp\":" + mireds + "}");
388 } else if (config.whiteThreshold != -1 && hsb.getSaturation().intValue() <= config.whiteThreshold) {
389 sendMQTT("{\"command\":\"set_white\"}");// Can't send the command and level in the same message.
390 sendMQTT("{\"level\":" + hsb.getBrightness().intValue() + "}");
392 sendMQTT("{\"state\":\"ON\",\"level\":" + hsb.getBrightness().intValue() + ",\"hue\":"
393 + hsb.getHue().intValue() + ",\"saturation\":" + hsb.getSaturation().intValue() + "}");
395 savedLevel = hsb.getBrightness().toBigDecimal();
397 } else if (command instanceof PercentType) {
398 PercentType percentType = (PercentType) command;
399 if (percentType.intValue() == 0) {
402 } else if (percentType.intValue() == 1 && config.oneTriggersNightMode) {
403 sendMQTT("{\"command\":\"night_mode\"}");
406 sendMQTT("{\"state\":\"ON\",\"level\":" + command + "}");
407 savedLevel = percentType.toBigDecimal();
409 if (config.dimmedCT > 0 && "white".equals(bulbMode)) {
410 sendMQTT("{\"state\":\"ON\",\"color_temp\":" + autoColourTemp(savedLevel.intValue()) + "}");
418 public void handleCommand(ChannelUID channelUID, Command command) {
419 if (command instanceof RefreshType) {
422 switch (channelUID.getId()) {
425 handleLevelColour(command);
427 case CHANNEL_BULB_MODE:
428 bulbMode = command.toString();
430 case CHANNEL_COLOURTEMP:
431 int scaledCommand = (int) Math.round((370 - (2.17 * Float.valueOf(command.toString()))));
432 sendMQTT("{\"state\":\"ON\",\"level\":" + savedLevel + ",\"color_temp\":" + scaledCommand + "}");
434 case CHANNEL_COMMAND:
435 sendMQTT("{\"command\":\"" + command + "\"}");
437 case CHANNEL_DISCO_MODE:
438 sendMQTT("{\"mode\":\"" + command + "\"}");
444 public void initialize() {
445 config = getConfigAs(ConfigOptions.class);
446 if (config.dimmedCT > 0) {
447 maxColourTemp = new BigDecimal(config.favouriteWhite);
448 minColourTemp = new BigDecimal(config.dimmedCT);
449 if (minColourTemp.intValue() <= maxColourTemp.intValue()) {
450 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
451 "The dimmedCT config value must be greater than the favourite White value.");
456 globeType = thing.getThingTypeUID().getId();// eg rgb_cct
457 String globeLocation = this.getThing().getUID().getId();// eg 0x014
458 remotesGroupID = globeLocation.substring(globeLocation.length() - 1, globeLocation.length());// eg 4
459 String remotesIDCode = globeLocation.substring(0, globeLocation.length() - 1);// eg 0x01
460 fullCommandTopic = COMMANDS_BASE_TOPIC + remotesIDCode + "/" + globeType + "/" + remotesGroupID;
461 fullStatesTopic = STATES_BASE_TOPIC + remotesIDCode + "/" + globeType + "/" + remotesGroupID;
462 // Need to remove the lowercase x from 0x12AB in case it contains all numbers
463 String caseCheck = globeLocation.substring(2, globeLocation.length() - 1);
464 if (!caseCheck.equals(caseCheck.toUpperCase())) {
465 logger.warn("The milight globe {}{} is using lowercase for the remote code when the hub needs UPPERCASE",
466 remotesIDCode, remotesGroupID);
468 channelPrefix = BINDING_ID + ":" + globeType + ":" + thing.getBridgeUID().getId() + ":" + remotesIDCode
469 + remotesGroupID + ":";
470 bridgeStatusChanged(getBridgeStatus());
473 private void sendMQTT(String payload) {
474 MqttBrokerConnection localConnection = connection;
475 if (localConnection != null) {
476 localConnection.publish(fullCommandTopic, payload.getBytes(), 1, false);
481 public void processMessage(String topic, byte[] payload) {
482 String state = new String(payload, StandardCharsets.UTF_8);
483 logger.trace("Received the following new Milight state:{}:{}", topic, state);
485 if (topic.equals(STATUS_TOPIC)) {
486 if (state.equals(CONNECTED)) {
487 updateStatus(ThingStatus.ONLINE);
489 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
490 "Waiting for 'milight/status: connected' MQTT message to be sent from your ESP Milight hub.");
494 processIncomingState(state);
495 } catch (Exception e) {
496 logger.warn("Failed processing Milight state {} for {}", state, topic, e);
501 public ThingStatusInfo getBridgeStatus() {
502 Bridge b = getBridge();
504 return b.getStatusInfo();
506 return new ThingStatusInfo(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE, null);
511 public void bridgeStatusChanged(ThingStatusInfo bridgeStatusInfo) {
512 if (bridgeStatusInfo.getStatus() == ThingStatus.OFFLINE) {
513 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
517 if (bridgeStatusInfo.getStatus() != ThingStatus.ONLINE) {
518 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
522 Bridge localBridge = this.getBridge();
523 if (localBridge == null) {
524 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED,
525 "Bridge is missing or offline, you need to setup a working MQTT broker first.");
528 ThingHandler handler = localBridge.getHandler();
529 if (handler instanceof AbstractBrokerHandler) {
530 AbstractBrokerHandler abh = (AbstractBrokerHandler) handler;
531 final MqttBrokerConnection connection;
533 connection = abh.getConnectionAsync().get(500, TimeUnit.MILLISECONDS);
534 } catch (InterruptedException | ExecutionException | TimeoutException ignored) {
535 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_UNINITIALIZED,
536 "Bridge handler has no valid broker connection!");
539 this.connection = connection;
540 updateStatus(ThingStatus.UNKNOWN, ThingStatusDetail.CONFIGURATION_PENDING,
541 "Waiting for 'milight/status: connected' MQTT message to be received. Check hub has 'MQTT Client Status Topic' configured.");
542 connection.subscribe(fullStatesTopic, this);
543 connection.subscribe(STATUS_TOPIC, this);
549 public void dispose() {
550 MqttBrokerConnection localConnection = connection;
551 if (localConnection != null) {
552 localConnection.unsubscribe(fullStatesTopic, this);
553 localConnection.unsubscribe(STATUS_TOPIC, this);