]> git.basschouten.com Git - openhab-addons.git/blob
7a057506895e3b0bc32e29b3226da44447743e90
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.govee.internal;
14
15 import static org.openhab.binding.govee.internal.GoveeBindingConstants.*;
16
17 import java.io.IOException;
18 import java.util.concurrent.ScheduledFuture;
19 import java.util.concurrent.TimeUnit;
20
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;
45
46 import com.google.gson.Gson;
47 import com.google.gson.JsonSyntaxException;
48
49 /**
50  * The {@link GoveeHandler} is responsible for handling commands, which are
51  * sent to one of the channels.
52  *
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.
57  *
58  * <ul>
59  * <li>The job per thing that triggers a new update is called <i>triggerStatusJob</i>. There are as many instances
60  * as things.</li>
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>
63  * </ul>
64  *
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.
68  *
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
71  *
72  * @author Stefan Höhn - Initial contribution
73  */
74 @NonNullByDefault
75 public class GoveeHandler extends BaseThingHandler {
76
77     /*
78      * Messages to be sent to the Govee devices
79      */
80     private static final Gson GSON = new Gson();
81
82     private final Logger logger = LoggerFactory.getLogger(GoveeHandler.class);
83
84     @Nullable
85     private ScheduledFuture<?> triggerStatusJob; // send device status update job
86     private GoveeConfiguration goveeConfiguration = new GoveeConfiguration();
87
88     private CommunicationManager communicationManager;
89
90     private int lastOnOff;
91     private int lastBrightness;
92     private HSBType lastColor = new HSBType();
93     private int lastColorTempInKelvin = COLOR_TEMPERATURE_MIN_VALUE.intValue();
94
95     /**
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
99      */
100     private final Runnable thingRefreshSender = () -> {
101         try {
102             triggerDeviceStatusRefresh();
103             if (!thing.getStatus().equals(ThingStatus.ONLINE)) {
104                 updateStatus(ThingStatus.ONLINE);
105             }
106         } catch (IOException e) {
107             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
108                     "@text/offline.communication-error.could-not-query-device [\"" + goveeConfiguration.hostname
109                             + "\"]");
110         }
111     };
112
113     public GoveeHandler(Thing thing, CommunicationManager communicationManager) {
114         super(thing);
115         this.communicationManager = communicationManager;
116     }
117
118     public String getHostname() {
119         return goveeConfiguration.hostname;
120     }
121
122     @Override
123     public void initialize() {
124         goveeConfiguration = getConfigAs(GoveeConfiguration.class);
125
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");
130             return;
131         }
132         updateStatus(ThingStatus.UNKNOWN);
133         communicationManager.registerHandler(this);
134         if (triggerStatusJob == null) {
135             logger.debug("Starting refresh trigger job for thing {} ", thing.getLabel());
136
137             triggerStatusJob = scheduler.scheduleWithFixedDelay(thingRefreshSender, 100,
138                     goveeConfiguration.refreshInterval * 1000L, TimeUnit.MILLISECONDS);
139         }
140     }
141
142     @Override
143     public void dispose() {
144         super.dispose();
145
146         ScheduledFuture<?> triggerStatusJobFuture = triggerStatusJob;
147         if (triggerStatusJobFuture != null) {
148             triggerStatusJobFuture.cancel(true);
149             triggerStatusJob = null;
150         }
151         communicationManager.unregisterHandler(this);
152     }
153
154     @Override
155     public void handleCommand(ChannelUID channelUID, Command command) {
156         try {
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");
161             } else {
162                 logger.debug("Channel ID {} type {}", channelUID.getId(), command.getClass());
163                 switch (channelUID.getId()) {
164                     case CHANNEL_COLOR:
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);
172                         }
173                         break;
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);
182                         }
183                         break;
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);
194                         }
195                         break;
196                 }
197             }
198             if (!thing.getStatus().equals(ThingStatus.ONLINE)) {
199                 updateStatus(ThingStatus.ONLINE);
200             }
201         } catch (IOException e) {
202             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
203                     "@text/offline.communication-error.could-not-query-device [\"" + goveeConfiguration.hostname
204                             + "\"]");
205         }
206     }
207
208     /**
209      * Initiate a refresh to our thing devicee
210      *
211      */
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);
217     }
218
219     public void sendColor(Color color) throws IOException {
220         lastColor = ColorUtil.rgbToHsb(new int[] { color.r(), color.g(), color.b() });
221
222         GenericGoveeRequest lightColor = new GenericGoveeRequest(
223                 new GenericGoveeMsg("colorwc", new ColorData(color, 0)));
224         communicationManager.sendRequest(this, lightColor);
225     }
226
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);
232     }
233
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);
239     }
240
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);
247     }
248
249     /**
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
253      *
254      * @see #lastColor
255      * @see #lastBrightness
256      * @see #lastOnOff
257      *
258      * @return the computed state
259      */
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);
265     }
266
267     void handleIncomingStatus(String response) {
268         if (response.isEmpty()) {
269             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
270                     "@text/offline.communication-error.empty-response");
271             return;
272         }
273
274         try {
275             StatusResponse statusMessage = GSON.fromJson(response, StatusResponse.class);
276             if (statusMessage != null) {
277                 updateDeviceState(statusMessage);
278             }
279             updateStatus(ThingStatus.ONLINE);
280         } catch (JsonSyntaxException jse) {
281             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, jse.getMessage());
282         }
283     }
284
285     public void updateDeviceState(@Nullable StatusResponse message) {
286         if (message == null) {
287             return;
288         }
289
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);
299
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();
305
306         HSBType adaptedColor = getColorState(newColor, newBrightness);
307
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);
313         }
314
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));
323         }
324
325         lastOnOff = newOnOff;
326         lastColor = adaptedColor;
327         lastBrightness = newBrightness;
328     }
329 }