2 * Copyright (c) 2010-2021 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.bluetooth.govee.internal;
15 import static org.openhab.binding.bluetooth.govee.internal.GoveeBindingConstants.*;
17 import java.nio.ByteBuffer;
18 import java.nio.ByteOrder;
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;
28 import javax.measure.Quantity;
29 import javax.measure.quantity.Dimensionless;
30 import javax.measure.quantity.Temperature;
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;
66 * @author Connor Petty - Initial contribution
70 public class GoveeHygrometerHandler extends ConnectedBluetoothHandler {
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");
76 private static final byte[] SCAN_HEADER = { (byte) 0xFF, (byte) 0x88, (byte) 0xEC };
78 private final Logger logger = LoggerFactory.getLogger(GoveeHygrometerHandler.class);
80 private final CommandSocket commandSocket = new CommandSocket();
82 private GoveeHygrometerConfiguration config = new GoveeHygrometerConfiguration();
83 private GoveeModel model = GoveeModel.H5074;// we use this as our default model
85 private CompletableFuture<?> initializeJob = CompletableFuture.completedFuture(null);// initially set to a dummy
87 private Future<?> scanJob = CompletableFuture.completedFuture(null);
88 private Future<?> keepAliveJob = CompletableFuture.completedFuture(null);
90 public GoveeHygrometerHandler(Thing thing) {
95 public void initialize() {
97 if (thing.getStatus() == ThingStatus.OFFLINE) {
98 // something went wrong in super.initialize() so we shouldn't initialize further here either
102 config = getConfigAs(GoveeHygrometerConfiguration.class);
104 Map<String, String> properties = thing.getProperties();
105 String modelProp = properties.get(Thing.PROPERTY_MODEL_ID);
106 model = GoveeModel.H5074;
107 if (modelProp != null) {
109 model = GoveeModel.valueOf(modelProp);
110 } catch (IllegalArgumentException ex) {
115 logger.debug("Initializing Govee Hygrometer {} model: {}", address, model);
116 initializeJob = RetryFuture.composeWithRetry(this::createInitSettingsJob, scheduler)//
118 updateStatus(ThingStatus.ONLINE);
120 scanJob = scheduler.scheduleWithFixedDelay(() -> {
122 if (initializeJob.isDone() && !initializeJob.isCompletedExceptionally()) {
123 logger.debug("refreshing temperature, humidity, and battery");
124 refreshBattery().join();
125 refreshTemperatureAndHumidity().join();
127 updateStatus(ThingStatus.ONLINE);
129 } catch (RuntimeException ex) {
130 logger.warn("unable to refresh", ex);
132 }, 0, config.refreshInterval, TimeUnit.SECONDS);
133 keepAliveJob = scheduler.scheduleWithFixedDelay(() -> {
134 if (device.getConnectionState() == ConnectionState.CONNECTED) {
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);
142 }, 1, 2, TimeUnit.SECONDS);
146 public void dispose() {
147 initializeJob.cancel(false);
148 scanJob.cancel(false);
149 keepAliveJob.cancel(false);
153 private CompletableFuture<@Nullable ?> createInitSettingsJob() {
155 logger.debug("Initializing Govee Hygrometer {} settings", address);
157 QuantityType<Temperature> temCali = config.getTemperatureCalibration();
158 QuantityType<Dimensionless> humCali = config.getHumidityCalibration();
159 WarningSettingsDTO<Temperature> temWarnSettings = config.getTemperatureWarningSettings();
160 WarningSettingsDTO<Dimensionless> humWarnSettings = config.getHumidityWarningSettings();
162 final CompletableFuture<@Nullable ?> parent = new HeritableFuture<>();
163 CompletableFuture<@Nullable ?> future = parent;
164 future.complete(null);
166 if (temCali != null) {
167 future = future.thenCompose(v -> {
168 CompletableFuture<@Nullable QuantityType<Temperature>> caliFuture = parent.newIncompleteFuture();
169 commandSocket.sendMessage(new GetOrSetTemCaliCommand(temCali, caliFuture));
173 if (humCali != null) {
174 future = future.thenCompose(v -> {
175 CompletableFuture<@Nullable QuantityType<Dimensionless>> caliFuture = parent.newIncompleteFuture();
176 commandSocket.sendMessage(new GetOrSetHumCaliCommand(humCali, caliFuture));
180 if (model.supportsWarningBroadcast()) {
181 future = future.thenCompose(v -> {
182 CompletableFuture<@Nullable WarningSettingsDTO<Temperature>> temWarnFuture = parent
183 .newIncompleteFuture();
184 commandSocket.sendMessage(new GetOrSetTemWarningCommand(temWarnSettings, temWarnFuture));
185 return temWarnFuture;
186 }).thenCompose(v -> {
187 CompletableFuture<@Nullable WarningSettingsDTO<Dimensionless>> humWarnFuture = parent
188 .newIncompleteFuture();
189 commandSocket.sendMessage(new GetOrSetHumWarningCommand(humWarnSettings, humWarnFuture));
190 return humWarnFuture;
194 // CompletableFuture.exceptionallyCompose isn't available yet so we have to compose it manually for now.
195 CompletableFuture<@Nullable Void> retFuture = future.newIncompleteFuture();
196 future.whenComplete((v, th) -> {
197 if (th instanceof CompletionException) {
200 if (th instanceof RuntimeException) {
201 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
202 "Failed to initialize device: " + th.getMessage());
203 retFuture.completeExceptionally(th);
204 } else if (th != null) {
205 logger.debug("Failure to initialize device: {}. Retrying in 30 seconds", th.getMessage());
206 retFuture.completeExceptionally(new RetryException(30, TimeUnit.SECONDS));
208 retFuture.complete(null);
215 public void handleCommand(ChannelUID channelUID, Command command) {
216 super.handleCommand(channelUID, command);
218 switch (channelUID.getId()) {
219 case CHANNEL_ID_BATTERY:
220 if (command == RefreshType.REFRESH) {
224 case CHANNEL_ID_TEMPERATURE:
225 case CHANNEL_ID_HUMIDITY:
226 if (command == RefreshType.REFRESH) {
227 refreshTemperatureAndHumidity();
233 private CompletableFuture<@Nullable ?> refreshBattery() {
234 CompletableFuture<@Nullable QuantityType<Dimensionless>> future = new CompletableFuture<>();
235 commandSocket.sendMessage(new GetBatteryCommand(future));
236 future.whenCompleteAsync(this::updateBattery, scheduler);
240 private void updateBattery(@Nullable QuantityType<Dimensionless> result, @Nullable Throwable th) {
242 logger.debug("Failed to get battery: {}", th.getMessage());
244 if (result == null) {
247 updateState(CHANNEL_ID_BATTERY, result);
250 private CompletableFuture<@Nullable ?> refreshTemperatureAndHumidity() {
251 CompletableFuture<@Nullable TemHumDTO> future = new CompletableFuture<>();
252 commandSocket.sendMessage(new GetTemHumCommand(future));
253 future.whenCompleteAsync(this::updateTemperatureAndHumidity, scheduler);
257 private void updateTemperatureAndHumidity(@Nullable TemHumDTO result, @Nullable Throwable th) {
259 logger.debug("Failed to get temperature/humidity: {}", th.getMessage());
261 if (result == null) {
264 QuantityType<Temperature> tem = result.temperature;
265 QuantityType<Dimensionless> hum = result.humidity;
266 if (tem == null || hum == null) {
269 updateState(CHANNEL_ID_TEMPERATURE, tem);
270 updateState(CHANNEL_ID_HUMIDITY, hum);
271 if (model.supportsWarningBroadcast()) {
272 updateAlarm(CHANNEL_ID_TEMPERATURE_ALARM, tem, config.getTemperatureWarningSettings());
273 updateAlarm(CHANNEL_ID_HUMIDITY_ALARM, hum, config.getHumidityWarningSettings());
277 private <T extends Quantity<T>> void updateAlarm(String channelName, QuantityType<T> quantity,
278 WarningSettingsDTO<T> settings) {
279 boolean outOfRange = quantity.compareTo(settings.min) < 0 || settings.max.compareTo(quantity) < 0;
280 updateState(channelName, OnOffType.from(outOfRange));
283 private int scanPacketSize() {
296 public void onScanRecordReceived(BluetoothScanNotification scanNotification) {
297 super.onScanRecordReceived(scanNotification);
298 byte[] scanData = scanNotification.getData();
299 int dataPacketSize = scanPacketSize();
300 int recordIndex = indexOfTemHumRecord(scanData);
301 if (recordIndex == -1 || recordIndex + dataPacketSize >= scanData.length) {
305 ByteBuffer data = ByteBuffer.wrap(scanData, recordIndex, dataPacketSize);
314 data.position(2);// we throw this away
318 data.order(ByteOrder.BIG_ENDIAN);
319 int l = data.getInt();
322 boolean positive = (l & 0x800000) == 0;
323 int tem = (short) ((l / 1000) * 10);
327 temperature = (short) tem;
328 humidity = (l % 1000) * 10;
329 battery = data.get();
332 data.order(ByteOrder.LITTLE_ENDIAN);
334 temperature = data.getShort();
335 humidity = data.getShort();
336 battery = Byte.toUnsignedInt(data.get());
342 data.order(ByteOrder.LITTLE_ENDIAN);
343 boolean hasWifi = data.get() == 0;
344 temperature = data.getShort();
345 humidity = Short.toUnsignedInt(data.getShort());
346 battery = Byte.toUnsignedInt(data.get());
347 wifiLevel = hasWifi ? Byte.toUnsignedInt(data.get()) : 0;
350 updateTemHumBattery(temperature, humidity, battery, wifiLevel);
353 private static int indexOfTemHumRecord(byte @Nullable [] scanData) {
354 if (scanData == null || scanData.length != 62) {
359 int recordLength = scanData[i] & 0xFF;
360 if (scanData[i + 1] == SCAN_HEADER[0]//
361 && scanData[i + 2] == SCAN_HEADER[1]//
362 && scanData[i + 3] == SCAN_HEADER[2]) {
366 i += recordLength + 1;
371 private void updateTemHumBattery(short tem, int hum, int battery, int wifiLevel) {
372 if (Short.toUnsignedInt(tem) == 0xFFFF || hum == 0xFFFF) {
373 logger.trace("Govee device [{}] received invalid data", this.address);
377 logger.debug("Govee device [{}] received broadcast: tem = {}, hum = {}, battery = {}, wifiLevel = {}",
378 this.address, tem, hum, battery, wifiLevel);
380 if (tem == 0 && hum == 0 && battery == 0) {
381 logger.trace("Govee device [{}] values are zero", this.address);
384 if (tem < -4000 || tem > 10000) {
385 logger.trace("Govee device [{}] invalid temperature value: {}", this.address, tem);
389 logger.trace("Govee device [{}] invalid humidity valie: {}", this.address, hum);
393 TemHumDTO temhum = new TemHumDTO();
394 temhum.temperature = new QuantityType<>(tem / 100.0, SIUnits.CELSIUS);
395 temhum.humidity = new QuantityType<>(hum / 100.0, Units.PERCENT);
396 updateTemperatureAndHumidity(temhum, null);
398 updateBattery(new QuantityType<>(battery, Units.PERCENT), null);
402 public void onCharacteristicUpdate(BluetoothCharacteristic characteristic, byte[] value) {
403 super.onCharacteristicUpdate(characteristic, value);
404 commandSocket.receivePacket(value);
407 private class CommandSocket extends SimpleGattSocket<GoveeMessage> {
410 protected ScheduledExecutorService getScheduler() {
415 public void sendMessage(MessageServicer<GoveeMessage, GoveeMessage> messageServicer) {
416 logger.debug("sending message: {}", messageServicer.getClass().getSimpleName());
417 super.sendMessage(messageServicer);
421 protected void parsePacket(byte[] packet, Consumer<GoveeMessage> messageHandler) {
422 messageHandler.accept(new GoveeMessage(packet));
426 protected CompletableFuture<@Nullable Void> sendPacket(byte[] data) {
427 return writeCharacteristic(SERVICE_UUID, PROTOCOL_CHAR_UUID, data, true);