]> git.basschouten.com Git - openhab-addons.git/blob
1909ecb5480cbb02d34f86042a2a2a0b6b5acc71
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.bluetooth.govee.internal;
14
15 import static org.openhab.binding.bluetooth.govee.internal.GoveeBindingConstants.*;
16
17 import java.nio.ByteBuffer;
18 import java.nio.ByteOrder;
19 import java.util.Map;
20 import java.util.UUID;
21 import java.util.concurrent.CompletableFuture;
22 import java.util.concurrent.CompletionException;
23 import java.util.concurrent.Future;
24 import java.util.concurrent.ScheduledExecutorService;
25 import java.util.concurrent.TimeUnit;
26 import java.util.function.Consumer;
27
28 import javax.measure.Quantity;
29 import javax.measure.quantity.Dimensionless;
30 import javax.measure.quantity.Temperature;
31
32 import org.eclipse.jdt.annotation.NonNullByDefault;
33 import org.eclipse.jdt.annotation.Nullable;
34 import org.openhab.binding.bluetooth.BluetoothCharacteristic;
35 import org.openhab.binding.bluetooth.BluetoothDevice.ConnectionState;
36 import org.openhab.binding.bluetooth.ConnectedBluetoothHandler;
37 import org.openhab.binding.bluetooth.gattserial.MessageServicer;
38 import org.openhab.binding.bluetooth.gattserial.SimpleGattSocket;
39 import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetBatteryCommand;
40 import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetOrSetHumCaliCommand;
41 import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetOrSetHumWarningCommand;
42 import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetOrSetTemCaliCommand;
43 import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetOrSetTemWarningCommand;
44 import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetTemHumCommand;
45 import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GoveeMessage;
46 import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.TemHumDTO;
47 import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.WarningSettingsDTO;
48 import org.openhab.binding.bluetooth.notification.BluetoothScanNotification;
49 import org.openhab.binding.bluetooth.util.HeritableFuture;
50 import org.openhab.binding.bluetooth.util.RetryException;
51 import org.openhab.binding.bluetooth.util.RetryFuture;
52 import org.openhab.core.library.types.OnOffType;
53 import org.openhab.core.library.types.QuantityType;
54 import org.openhab.core.library.unit.SIUnits;
55 import org.openhab.core.library.unit.Units;
56 import org.openhab.core.thing.ChannelUID;
57 import org.openhab.core.thing.Thing;
58 import org.openhab.core.thing.ThingStatus;
59 import org.openhab.core.thing.ThingStatusDetail;
60 import org.openhab.core.types.Command;
61 import org.openhab.core.types.RefreshType;
62 import org.slf4j.Logger;
63 import org.slf4j.LoggerFactory;
64
65 /**
66  * @author Connor Petty - Initial contribution
67  *
68  */
69 @NonNullByDefault
70 public class GoveeHygrometerHandler extends ConnectedBluetoothHandler {
71
72     private static final UUID SERVICE_UUID = UUID.fromString("494e5445-4c4c-495f-524f-434b535f4857");
73     private static final UUID PROTOCOL_CHAR_UUID = UUID.fromString("494e5445-4c4c-495f-524f-434b535f2011");
74     private static final UUID KEEP_ALIVE_CHAR_UUID = UUID.fromString("494e5445-4c4c-495f-524f-434b535f2012");
75
76     private static final byte[] SCAN_HEADER = { (byte) 0xFF, (byte) 0x88, (byte) 0xEC };
77
78     private final Logger logger = LoggerFactory.getLogger(GoveeHygrometerHandler.class);
79
80     private final CommandSocket commandSocket = new CommandSocket();
81
82     private GoveeHygrometerConfiguration config = new GoveeHygrometerConfiguration();
83     private GoveeModel model = GoveeModel.H5074;// we use this as our default model
84
85     private CompletableFuture<?> initializeJob = CompletableFuture.completedFuture(null);// initially set to a dummy
86                                                                                          // future
87     private Future<?> scanJob = CompletableFuture.completedFuture(null);
88     private Future<?> keepAliveJob = CompletableFuture.completedFuture(null);
89
90     public GoveeHygrometerHandler(Thing thing) {
91         super(thing);
92     }
93
94     @Override
95     public void initialize() {
96         super.initialize();
97         if (thing.getStatus() == ThingStatus.OFFLINE) {
98             // something went wrong in super.initialize() so we shouldn't initialize further here either
99             return;
100         }
101
102         config = getConfigAs(GoveeHygrometerConfiguration.class);
103
104         Map<String, String> properties = thing.getProperties();
105         String modelProp = properties.get(Thing.PROPERTY_MODEL_ID);
106         model = GoveeModel.H5074;
107         if (modelProp != null) {
108             try {
109                 model = GoveeModel.valueOf(modelProp);
110             } catch (IllegalArgumentException ex) {
111                 // ignore
112             }
113         }
114
115         logger.debug("Initializing Govee Hygrometer {} model: {}", address, model);
116         initializeJob = RetryFuture.composeWithRetry(this::createInitSettingsJob, scheduler)//
117                 .thenRun(() -> {
118                     updateStatus(ThingStatus.ONLINE);
119                 });
120         scanJob = scheduler.scheduleWithFixedDelay(() -> {
121             try {
122                 if (initializeJob.isDone() && !initializeJob.isCompletedExceptionally()) {
123                     logger.debug("refreshing temperature, humidity, and battery");
124                     refreshBattery().join();
125                     refreshTemperatureAndHumidity().join();
126                     disconnect();
127                     updateStatus(ThingStatus.ONLINE);
128                 }
129             } catch (RuntimeException ex) {
130                 logger.warn("unable to refresh", ex);
131             }
132         }, 0, config.refreshInterval, TimeUnit.SECONDS);
133         keepAliveJob = scheduler.scheduleWithFixedDelay(() -> {
134             if (device.getConnectionState() == ConnectionState.CONNECTED) {
135                 try {
136                     GoveeMessage message = new GoveeMessage((byte) 0xAA, (byte) 1, null);
137                     writeCharacteristic(SERVICE_UUID, KEEP_ALIVE_CHAR_UUID, message.getPayload(), false);
138                 } catch (RuntimeException ex) {
139                     logger.warn("unable to send keep alive", ex);
140                 }
141             }
142         }, 1, 2, TimeUnit.SECONDS);
143     }
144
145     @Override
146     public void dispose() {
147         initializeJob.cancel(false);
148         scanJob.cancel(false);
149         keepAliveJob.cancel(false);
150         super.dispose();
151     }
152
153     private CompletableFuture<@Nullable ?> createInitSettingsJob() {
154         logger.debug("Initializing Govee Hygrometer {} settings", address);
155
156         QuantityType<Temperature> temCali = config.getTemperatureCalibration();
157         QuantityType<Dimensionless> humCali = config.getHumidityCalibration();
158         WarningSettingsDTO<Temperature> temWarnSettings = config.getTemperatureWarningSettings();
159         WarningSettingsDTO<Dimensionless> humWarnSettings = config.getHumidityWarningSettings();
160
161         final CompletableFuture<@Nullable ?> parent = new HeritableFuture<>();
162         CompletableFuture<@Nullable ?> future = parent;
163         future.complete(null);
164
165         if (temCali != null) {
166             future = future.thenCompose(v -> {
167                 CompletableFuture<@Nullable QuantityType<Temperature>> caliFuture = parent.newIncompleteFuture();
168                 commandSocket.sendMessage(new GetOrSetTemCaliCommand(temCali, caliFuture));
169                 return caliFuture;
170             });
171         }
172         if (humCali != null) {
173             future = future.thenCompose(v -> {
174                 CompletableFuture<@Nullable QuantityType<Dimensionless>> caliFuture = parent.newIncompleteFuture();
175                 commandSocket.sendMessage(new GetOrSetHumCaliCommand(humCali, caliFuture));
176                 return caliFuture;
177             });
178         }
179         if (model.supportsWarningBroadcast()) {
180             future = future.thenCompose(v -> {
181                 CompletableFuture<@Nullable WarningSettingsDTO<Temperature>> temWarnFuture = parent
182                         .newIncompleteFuture();
183                 commandSocket.sendMessage(new GetOrSetTemWarningCommand(temWarnSettings, temWarnFuture));
184                 return temWarnFuture;
185             }).thenCompose(v -> {
186                 CompletableFuture<@Nullable WarningSettingsDTO<Dimensionless>> humWarnFuture = parent
187                         .newIncompleteFuture();
188                 commandSocket.sendMessage(new GetOrSetHumWarningCommand(humWarnSettings, humWarnFuture));
189                 return humWarnFuture;
190             });
191         }
192
193         // CompletableFuture.exceptionallyCompose isn't available yet so we have to compose it manually for now.
194         CompletableFuture<@Nullable Void> retFuture = future.newIncompleteFuture();
195         future.whenComplete((v, th) -> {
196             if (th instanceof CompletionException) {
197                 th = th.getCause();
198             }
199             if (th instanceof RuntimeException) {
200                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
201                         "Failed to initialize device: " + th.getMessage());
202                 retFuture.completeExceptionally(th);
203             } else if (th != null) {
204                 logger.debug("Failure to initialize device: {}. Retrying in 30 seconds", th.getMessage());
205                 retFuture.completeExceptionally(new RetryException(30, TimeUnit.SECONDS));
206             } else {
207                 retFuture.complete(null);
208             }
209         });
210         return retFuture;
211     }
212
213     @Override
214     public void handleCommand(ChannelUID channelUID, Command command) {
215         super.handleCommand(channelUID, command);
216
217         switch (channelUID.getId()) {
218             case CHANNEL_ID_BATTERY:
219                 if (command == RefreshType.REFRESH) {
220                     refreshBattery();
221                 }
222                 return;
223             case CHANNEL_ID_TEMPERATURE:
224             case CHANNEL_ID_HUMIDITY:
225                 if (command == RefreshType.REFRESH) {
226                     refreshTemperatureAndHumidity();
227                 }
228                 return;
229         }
230     }
231
232     private CompletableFuture<@Nullable ?> refreshBattery() {
233         CompletableFuture<@Nullable QuantityType<Dimensionless>> future = new CompletableFuture<>();
234         commandSocket.sendMessage(new GetBatteryCommand(future));
235         future.whenCompleteAsync(this::updateBattery, scheduler);
236         return future;
237     }
238
239     private void updateBattery(@Nullable QuantityType<Dimensionless> result, @Nullable Throwable th) {
240         if (th != null) {
241             logger.debug("Failed to get battery: {}", th.getMessage());
242         }
243         if (result == null) {
244             return;
245         }
246         updateState(CHANNEL_ID_BATTERY, result);
247     }
248
249     private CompletableFuture<@Nullable ?> refreshTemperatureAndHumidity() {
250         CompletableFuture<@Nullable TemHumDTO> future = new CompletableFuture<>();
251         commandSocket.sendMessage(new GetTemHumCommand(future));
252         future.whenCompleteAsync(this::updateTemperatureAndHumidity, scheduler);
253         return future;
254     }
255
256     private void updateTemperatureAndHumidity(@Nullable TemHumDTO result, @Nullable Throwable th) {
257         if (th != null) {
258             logger.debug("Failed to get temperature/humidity: {}", th.getMessage());
259         }
260         if (result == null) {
261             return;
262         }
263         QuantityType<Temperature> tem = result.temperature;
264         QuantityType<Dimensionless> hum = result.humidity;
265         if (tem == null || hum == null) {
266             return;
267         }
268         updateState(CHANNEL_ID_TEMPERATURE, tem);
269         updateState(CHANNEL_ID_HUMIDITY, hum);
270         if (model.supportsWarningBroadcast()) {
271             updateAlarm(CHANNEL_ID_TEMPERATURE_ALARM, tem, config.getTemperatureWarningSettings());
272             updateAlarm(CHANNEL_ID_HUMIDITY_ALARM, hum, config.getHumidityWarningSettings());
273         }
274     }
275
276     private <T extends Quantity<T>> void updateAlarm(String channelName, QuantityType<T> quantity,
277             WarningSettingsDTO<T> settings) {
278         boolean outOfRange = quantity.compareTo(settings.min) < 0 || settings.max.compareTo(quantity) < 0;
279         updateState(channelName, OnOffType.from(outOfRange));
280     }
281
282     private int scanPacketSize() {
283         switch (model) {
284             case B5175:
285             case B5178:
286                 return 10;
287             case H5179:
288                 return 8;
289             default:
290                 return 7;
291         }
292     }
293
294     @Override
295     public void onScanRecordReceived(BluetoothScanNotification scanNotification) {
296         super.onScanRecordReceived(scanNotification);
297         byte[] scanData = scanNotification.getData();
298         int dataPacketSize = scanPacketSize();
299         int recordIndex = indexOfTemHumRecord(scanData);
300         if (recordIndex == -1 || recordIndex + dataPacketSize >= scanData.length) {
301             return;
302         }
303
304         ByteBuffer data = ByteBuffer.wrap(scanData, recordIndex, dataPacketSize);
305
306         short temperature;
307         int humidity;
308         int battery;
309         int wifiLevel = 0;
310
311         switch (model) {
312             default:
313                 data.position(2);// we throw this away
314                 // fall through
315             case H5072:
316             case H5075:
317                 data.order(ByteOrder.BIG_ENDIAN);
318                 int l = data.getInt();
319                 l = l & 0xFFFFFF;
320
321                 boolean positive = (l & 0x800000) == 0;
322                 int tem = (short) ((l / 1000) * 10);
323                 if (!positive) {
324                     tem = -tem;
325                 }
326                 temperature = (short) tem;
327                 humidity = (l % 1000) * 10;
328                 battery = data.get();
329                 break;
330             case H5179:
331                 data.order(ByteOrder.LITTLE_ENDIAN);
332                 data.position(3);
333                 temperature = data.getShort();
334                 humidity = data.getShort();
335                 battery = Byte.toUnsignedInt(data.get());
336                 break;
337             case H5051:
338             case H5052:
339             case H5071:
340             case H5074:
341                 data.order(ByteOrder.LITTLE_ENDIAN);
342                 boolean hasWifi = data.get() == 0;
343                 temperature = data.getShort();
344                 humidity = Short.toUnsignedInt(data.getShort());
345                 battery = Byte.toUnsignedInt(data.get());
346                 wifiLevel = hasWifi ? Byte.toUnsignedInt(data.get()) : 0;
347                 break;
348         }
349         updateTemHumBattery(temperature, humidity, battery, wifiLevel);
350     }
351
352     private static int indexOfTemHumRecord(byte @Nullable [] scanData) {
353         if (scanData == null || scanData.length != 62) {
354             return -1;
355         }
356         int i = 0;
357         while (i < 57) {
358             int recordLength = scanData[i] & 0xFF;
359             if (scanData[i + 1] == SCAN_HEADER[0]//
360                     && scanData[i + 2] == SCAN_HEADER[1]//
361                     && scanData[i + 3] == SCAN_HEADER[2]) {
362                 return i + 4;
363             }
364
365             i += recordLength + 1;
366         }
367         return -1;
368     }
369
370     private void updateTemHumBattery(short tem, int hum, int battery, int wifiLevel) {
371         if (Short.toUnsignedInt(tem) == 0xFFFF || hum == 0xFFFF) {
372             logger.trace("Govee device [{}] received invalid data", this.address);
373             return;
374         }
375
376         logger.debug("Govee device [{}] received broadcast: tem = {}, hum = {}, battery = {}, wifiLevel = {}",
377                 this.address, tem, hum, battery, wifiLevel);
378
379         if (tem == 0 && hum == 0 && battery == 0) {
380             logger.trace("Govee device [{}] values are zero", this.address);
381             return;
382         }
383         if (tem < -4000 || tem > 10000) {
384             logger.trace("Govee device [{}] invalid temperature value: {}", this.address, tem);
385             return;
386         }
387         if (hum > 10000) {
388             logger.trace("Govee device [{}] invalid humidity valie: {}", this.address, hum);
389             return;
390         }
391
392         TemHumDTO temhum = new TemHumDTO();
393         temhum.temperature = new QuantityType<>(tem / 100.0, SIUnits.CELSIUS);
394         temhum.humidity = new QuantityType<>(hum / 100.0, Units.PERCENT);
395         updateTemperatureAndHumidity(temhum, null);
396
397         updateBattery(new QuantityType<>(battery, Units.PERCENT), null);
398     }
399
400     @Override
401     public void onCharacteristicUpdate(BluetoothCharacteristic characteristic, byte[] value) {
402         super.onCharacteristicUpdate(characteristic, value);
403         commandSocket.receivePacket(value);
404     }
405
406     private class CommandSocket extends SimpleGattSocket<GoveeMessage> {
407
408         @Override
409         protected ScheduledExecutorService getScheduler() {
410             return scheduler;
411         }
412
413         @Override
414         public void sendMessage(MessageServicer<GoveeMessage, GoveeMessage> messageServicer) {
415             logger.debug("sending message: {}", messageServicer.getClass().getSimpleName());
416             super.sendMessage(messageServicer);
417         }
418
419         @Override
420         protected void parsePacket(byte[] packet, Consumer<GoveeMessage> messageHandler) {
421             messageHandler.accept(new GoveeMessage(packet));
422         }
423
424         @Override
425         protected CompletableFuture<@Nullable Void> sendPacket(byte[] data) {
426             return writeCharacteristic(SERVICE_UUID, PROTOCOL_CHAR_UUID, data, true);
427         }
428     }
429 }