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