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
13 package org.openhab.binding.govee.internal;
15 import static org.openhab.binding.govee.internal.GoveeBindingConstants.*;
17 import java.io.IOException;
18 import java.util.concurrent.ScheduledFuture;
19 import java.util.concurrent.TimeUnit;
21 import org.eclipse.jdt.annotation.NonNullByDefault;
22 import org.eclipse.jdt.annotation.Nullable;
23 import org.openhab.binding.govee.internal.model.Color;
24 import org.openhab.binding.govee.internal.model.ColorData;
25 import org.openhab.binding.govee.internal.model.EmptyValueQueryStatusData;
26 import org.openhab.binding.govee.internal.model.GenericGoveeMsg;
27 import org.openhab.binding.govee.internal.model.GenericGoveeRequest;
28 import org.openhab.binding.govee.internal.model.StatusResponse;
29 import org.openhab.binding.govee.internal.model.ValueIntData;
30 import org.openhab.core.library.types.HSBType;
31 import org.openhab.core.library.types.OnOffType;
32 import org.openhab.core.library.types.PercentType;
33 import org.openhab.core.library.types.QuantityType;
34 import org.openhab.core.library.unit.Units;
35 import org.openhab.core.thing.ChannelUID;
36 import org.openhab.core.thing.Thing;
37 import org.openhab.core.thing.ThingStatus;
38 import org.openhab.core.thing.ThingStatusDetail;
39 import org.openhab.core.thing.binding.BaseThingHandler;
40 import org.openhab.core.types.Command;
41 import org.openhab.core.types.RefreshType;
42 import org.openhab.core.util.ColorUtil;
43 import org.slf4j.Logger;
44 import org.slf4j.LoggerFactory;
46 import com.google.gson.Gson;
47 import com.google.gson.JsonSyntaxException;
50 * The {@link GoveeHandler} is responsible for handling commands, which are
51 * sent to one of the channels.
53 * Any device has its own job that triggers a refresh of retrieving the external state from the device.
54 * However, there must be only one job that listens for all devices in a singleton thread because
55 * all devices send their udp packet response to the same port on openHAB. Based on the sender IP address
56 * of the device we can detect to which thing the status answer needs to be assigned to and updated.
59 * <li>The job per thing that triggers a new update is called <i>triggerStatusJob</i>. There are as many instances
61 * <li>The job that receives the answers and applies that to the respective thing is called <i>refreshStatusJob</i> and
62 * there is only one for all instances. It may be stopped and restarted by the DiscoveryService (see below).</li>
65 * The other topic that needs to be managed is that device discovery responses are also sent to openHAB at the same port
66 * as status updates. Therefore, when scanning new devices that job that listens to status devices must
67 * be stopped while scanning new devices. Otherwise, the status job will receive the scan discover UDB packages.
69 * Controlling the lights is done via the Govee LAN API (cloud is not supported):
70 * https://app-h5.govee.com/user-manual/wlan-guide
72 * @author Stefan Höhn - Initial contribution
75 public class GoveeHandler extends BaseThingHandler {
78 * Messages to be sent to the Govee devices
80 private static final Gson GSON = new Gson();
82 private final Logger logger = LoggerFactory.getLogger(GoveeHandler.class);
85 private ScheduledFuture<?> triggerStatusJob; // send device status update job
86 private GoveeConfiguration goveeConfiguration = new GoveeConfiguration();
88 private CommunicationManager communicationManager;
90 private int lastOnOff;
91 private int lastBrightness;
92 private HSBType lastColor = new HSBType();
93 private int lastColorTempInKelvin = COLOR_TEMPERATURE_MIN_VALUE.intValue();
96 * This thing related job <i>thingRefreshSender</i> triggers an update to the Govee device.
97 * The device sends it back to the common port and the response is
98 * then received by the common #refreshStatusReceiver
100 private final Runnable thingRefreshSender = () -> {
102 triggerDeviceStatusRefresh();
103 if (!thing.getStatus().equals(ThingStatus.ONLINE)) {
104 updateStatus(ThingStatus.ONLINE);
106 } catch (IOException e) {
107 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
108 "@text/offline.communication-error.could-not-query-device [\"" + goveeConfiguration.hostname
113 public GoveeHandler(Thing thing, CommunicationManager communicationManager) {
115 this.communicationManager = communicationManager;
118 public String getHostname() {
119 return goveeConfiguration.hostname;
123 public void initialize() {
124 goveeConfiguration = getConfigAs(GoveeConfiguration.class);
126 final String ipAddress = goveeConfiguration.hostname;
127 if (ipAddress.isEmpty()) {
128 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
129 "@text/offline.configuration-error.ip-address.missing");
132 updateStatus(ThingStatus.UNKNOWN);
133 communicationManager.registerHandler(this);
134 if (triggerStatusJob == null) {
135 logger.debug("Starting refresh trigger job for thing {} ", thing.getLabel());
137 triggerStatusJob = scheduler.scheduleWithFixedDelay(thingRefreshSender, 100,
138 goveeConfiguration.refreshInterval * 1000L, TimeUnit.MILLISECONDS);
143 public void dispose() {
146 ScheduledFuture<?> triggerStatusJobFuture = triggerStatusJob;
147 if (triggerStatusJobFuture != null) {
148 triggerStatusJobFuture.cancel(true);
149 triggerStatusJob = null;
151 communicationManager.unregisterHandler(this);
155 public void handleCommand(ChannelUID channelUID, Command command) {
157 if (command instanceof RefreshType) {
158 // we are refreshing all channels at once, as we get all information at the same time
159 triggerDeviceStatusRefresh();
160 logger.debug("Triggering Refresh");
162 logger.debug("Channel ID {} type {}", channelUID.getId(), command.getClass());
163 switch (channelUID.getId()) {
165 if (command instanceof HSBType hsbCommand) {
166 int[] rgb = ColorUtil.hsbToRgb(hsbCommand);
167 sendColor(new Color(rgb[0], rgb[1], rgb[2]));
168 } else if (command instanceof PercentType percent) {
169 sendBrightness(percent.intValue());
170 } else if (command instanceof OnOffType onOffCommand) {
171 sendOnOff(onOffCommand);
174 case CHANNEL_COLOR_TEMPERATURE:
175 if (command instanceof PercentType percent) {
176 logger.debug("COLOR_TEMPERATURE: Color Temperature change with Percent Type {}", command);
177 Double colorTemp = (COLOR_TEMPERATURE_MIN_VALUE + percent.intValue()
178 * (COLOR_TEMPERATURE_MAX_VALUE - COLOR_TEMPERATURE_MIN_VALUE) / 100.0);
179 lastColorTempInKelvin = colorTemp.intValue();
180 logger.debug("lastColorTempInKelvin {}", lastColorTempInKelvin);
181 sendColorTemp(lastColorTempInKelvin);
184 case CHANNEL_COLOR_TEMPERATURE_ABS:
185 if (command instanceof QuantityType<?> quantity) {
186 logger.debug("Color Temperature Absolute change with Percent Type {}", command);
187 lastColorTempInKelvin = quantity.intValue();
188 logger.debug("COLOR_TEMPERATURE_ABS: lastColorTempInKelvin {}", lastColorTempInKelvin);
189 int lastColorTempInPercent = ((Double) ((lastColorTempInKelvin
190 - COLOR_TEMPERATURE_MIN_VALUE)
191 / (COLOR_TEMPERATURE_MAX_VALUE - COLOR_TEMPERATURE_MIN_VALUE) * 100.0)).intValue();
192 logger.debug("computed lastColorTempInPercent {}", lastColorTempInPercent);
193 sendColorTemp(lastColorTempInKelvin);
198 if (!thing.getStatus().equals(ThingStatus.ONLINE)) {
199 updateStatus(ThingStatus.ONLINE);
201 } catch (IOException e) {
202 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
203 "@text/offline.communication-error.could-not-query-device [\"" + goveeConfiguration.hostname
209 * Initiate a refresh to our thing devicee
212 private void triggerDeviceStatusRefresh() throws IOException {
213 logger.debug("trigger Refresh Status of device {}", thing.getLabel());
214 GenericGoveeRequest lightQuery = new GenericGoveeRequest(
215 new GenericGoveeMsg("devStatus", new EmptyValueQueryStatusData()));
216 communicationManager.sendRequest(this, lightQuery);
219 public void sendColor(Color color) throws IOException {
220 lastColor = ColorUtil.rgbToHsb(new int[] { color.r(), color.g(), color.b() });
222 GenericGoveeRequest lightColor = new GenericGoveeRequest(
223 new GenericGoveeMsg("colorwc", new ColorData(color, 0)));
224 communicationManager.sendRequest(this, lightColor);
227 public void sendBrightness(int brightness) throws IOException {
228 lastBrightness = brightness;
229 GenericGoveeRequest lightBrightness = new GenericGoveeRequest(
230 new GenericGoveeMsg("brightness", new ValueIntData(brightness)));
231 communicationManager.sendRequest(this, lightBrightness);
234 private void sendOnOff(OnOffType switchValue) throws IOException {
235 lastOnOff = (switchValue == OnOffType.ON) ? 1 : 0;
236 GenericGoveeRequest switchLight = new GenericGoveeRequest(
237 new GenericGoveeMsg("turn", new ValueIntData(lastOnOff)));
238 communicationManager.sendRequest(this, switchLight);
241 private void sendColorTemp(int colorTemp) throws IOException {
242 lastColorTempInKelvin = colorTemp;
243 logger.debug("sendColorTemp {}", colorTemp);
244 GenericGoveeRequest lightColor = new GenericGoveeRequest(
245 new GenericGoveeMsg("colorwc", new ColorData(new Color(0, 0, 0), colorTemp)));
246 communicationManager.sendRequest(this, lightColor);
250 * Creates a Color state by using the last color information from lastColor
251 * The brightness is overwritten either by the provided lastBrightness
252 * or if lastOnOff = 0 (off) then the brightness is set 0
255 * @see #lastBrightness
258 * @return the computed state
260 private HSBType getColorState(Color color, int brightness) {
261 PercentType computedBrightness = lastOnOff == 0 ? new PercentType(0) : new PercentType(brightness);
262 int[] rgb = { color.r(), color.g(), color.b() };
263 HSBType hsb = ColorUtil.rgbToHsb(rgb);
264 return new HSBType(hsb.getHue(), hsb.getSaturation(), computedBrightness);
267 void handleIncomingStatus(String response) {
268 if (response.isEmpty()) {
269 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
270 "@text/offline.communication-error.empty-response");
275 StatusResponse statusMessage = GSON.fromJson(response, StatusResponse.class);
276 if (statusMessage != null) {
277 updateDeviceState(statusMessage);
279 updateStatus(ThingStatus.ONLINE);
280 } catch (JsonSyntaxException jse) {
281 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, jse.getMessage());
285 public void updateDeviceState(@Nullable StatusResponse message) {
286 if (message == null) {
290 logger.trace("Receiving Device State");
291 int newOnOff = message.msg().data().onOff();
292 logger.trace("newOnOff = {}", newOnOff);
293 int newBrightness = message.msg().data().brightness();
294 logger.trace("newBrightness = {}", newBrightness);
295 Color newColor = message.msg().data().color();
296 logger.trace("newColor = {}", newColor);
297 int newColorTempInKelvin = message.msg().data().colorTemInKelvin();
298 logger.trace("newColorTempInKelvin = {}", newColorTempInKelvin);
300 newColorTempInKelvin = (newColorTempInKelvin < COLOR_TEMPERATURE_MIN_VALUE)
301 ? COLOR_TEMPERATURE_MIN_VALUE.intValue()
302 : newColorTempInKelvin;
303 int newColorTempInPercent = ((Double) ((newColorTempInKelvin - COLOR_TEMPERATURE_MIN_VALUE)
304 / (COLOR_TEMPERATURE_MAX_VALUE - COLOR_TEMPERATURE_MIN_VALUE) * 100.0)).intValue();
306 HSBType adaptedColor = getColorState(newColor, newBrightness);
308 logger.trace("HSB old: {} vs adaptedColor: {}", lastColor, adaptedColor);
309 // avoid noise by only updating if the value has changed on the device
310 if (!adaptedColor.equals(lastColor)) {
311 logger.trace("UPDATING HSB old: {} != {}", lastColor, adaptedColor);
312 updateState(CHANNEL_COLOR, adaptedColor);
315 // avoid noise by only updating if the value has changed on the device
316 logger.trace("Color-Temperature Status: old: {} K {}% vs new: {} K", lastColorTempInKelvin,
317 newColorTempInPercent, newColorTempInKelvin);
318 if (newColorTempInKelvin != lastColorTempInKelvin) {
319 logger.trace("Color-Temperature Status: old: {} K {}% vs new: {} K", lastColorTempInKelvin,
320 newColorTempInPercent, newColorTempInKelvin);
321 updateState(CHANNEL_COLOR_TEMPERATURE_ABS, new QuantityType<>(lastColorTempInKelvin, Units.KELVIN));
322 updateState(CHANNEL_COLOR_TEMPERATURE, new PercentType(newColorTempInPercent));
325 lastOnOff = newOnOff;
326 lastColor = adaptedColor;
327 lastBrightness = newBrightness;