/bundles/org.openhab.binding.bluetooth.daikinmadoka/ @blafois
/bundles/org.openhab.binding.bluetooth.enoceanble/ @pfink
/bundles/org.openhab.binding.bluetooth.generic/ @cpmeister
+/bundles/org.openhab.binding.bluetooth.govee/ @cpmeister
/bundles/org.openhab.binding.bluetooth.roaming/ @cpmeister
/bundles/org.openhab.binding.bluetooth.ruuvitag/ @ssalonen
/bundles/org.openhab.binding.boschindego/ @jofleck
<artifactId>org.openhab.binding.bluetooth.generic</artifactId>
<version>${project.version}</version>
</dependency>
+ <dependency>
+ <groupId>org.openhab.addons.bundles</groupId>
+ <artifactId>org.openhab.binding.bluetooth.govee</artifactId>
+ <version>${project.version}</version>
+ </dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.bluetooth.roaming</artifactId>
--- /dev/null
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
--- /dev/null
+# Govee
+
+This extension adds support for [Govee](https://www.govee.com/) Bluetooth Devices.
+
+## Supported Things
+
+Only two thing types are supported by this extension at the moment.
+
+| Thing Type ID | Description | Supported Models |
+|------------------------|-------------------------------------------|-------------------------------------------------------------|
+| goveeHygrometer | Govee Thermo-Hygrometer | H5051,H5071 |
+| goveeHygrometerMonitor | Govee Thermo-Hygrometer w/ Warning Alarms | H5052,H5072,H5074,H5075,H5101,H5102,H5177,H5179,B5175,B5178 |
+
+## Discovery
+
+As any other Bluetooth device, Govee devices are discovered automatically by the corresponding bridge.
+
+## Thing Configuration
+
+Govee things have the following configuration parameters:
+
+| Thing | Parameter | Required | Default | Description |
+|-----------------------------|-------------------------|----------|---------|-----------------------------------------------------------------------------------|
+| all | address | yes | | The Bluetooth address of the device (in format "XX:XX:XX:XX:XX:XX") |
+| all | refreshInterval | | 300 | How often, in seconds, the sensor data of the device should be refreshed |
+| goveeHygrometer<sup>1</sup> | temperatureCalibration | no | | Offset to apply to temperature<sup>2</sup> sensor readings |
+| goveeHygrometer<sup>1</sup> | humidityCalibration | no | | Offset to apply to humidity sensor readings |
+| goveeHygrometerMonitor | temperatureWarningAlarm | | false | Enables warning alarms to be broadcast when temperature is out of specified range |
+| goveeHygrometerMonitor | temperatureWarningMin | | 0 | The lower safe temperature<sup>2</sup> threshold <sup>3</sup> |
+| goveeHygrometerMonitor | temperatureWarningMax | | 0 | The upper safe temperature<sup>2</sup> threshold <sup>3</sup> |
+| goveeHygrometerMonitor | humidityWarningAlarm | | false | Enables warning alarms to be broadcast when humidity is out of specified range |
+| goveeHygrometerMonitor | humidityWarningMin | | 0 | The lower safe humidity threshold <sup>3</sup> |
+| goveeHygrometerMonitor | humidityWarningMax | | 0 | The upper safe humidity threshold <sup>3</sup> |
+
+1. Available to both `goveeHygrometer` and `goveeHygrometerMonitor` thing types.
+2. In °C
+3. Only applies if alarm feature is enabled
+
+## Channels
+
+Govee things have the following channels in addition to the default bluetooth channels:
+
+| Thing | Channel ID | Item Type | Description |
+|-----------------------------|------------------|------------------------|----------------------------------------------------------------|
+| goveeHygrometer<sup>1</sup> | temperature | Number:Temperature | The measured temperature |
+| goveeHygrometer<sup>1</sup> | humidity | Number:Dimensionless | The measured relative humidity |
+| goveeHygrometer<sup>1</sup> | battery | Number:Dimensionless | The measured battery percentage |
+| goveeHygrometerMonitor | temperatureAlarm | Switch | Indicates if current temperature is out of range. <sup>2</sup> |
+| goveeHygrometerMonitor | humidityAlarm | Switch | Indicates if current humidity is out of range. <sup>2</sup> |
+
+1. Available to both `goveeHygrometer` and `goveeHygrometerMonitor` thing types.
+2. Only applies if warning alarms are enabled in the configuration.
+
+## Example
+
+demo.things:
+
+```
+bluetooth:goveeHygrometer:hci0:beacon "Govee Temperature Humidity Monitor" (bluetooth:bluez:hci0) [ address="12:34:56:78:9A:BC" ]
+```
+
+demo.items:
+
+```
+Number:Temperature temperature "Room Temperature [%.1f %unit%]" { channel="bluetooth:goveeHygrometer:hci0:beacon:temperature" }
+Number:Dimensionless humidity "Humidity [%.0f %unit%]" { channel="bluetooth:goveeHygrometer:hci0:beacon:humidity" }
+Number:Dimensionless battery "Battery [%.0f %unit%]" { channel="bluetooth:goveeHygrometer:hci0:beacon:battery" }
+```
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
+ xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
+
+ <modelVersion>4.0.0</modelVersion>
+
+ <parent>
+ <groupId>org.openhab.addons.bundles</groupId>
+ <artifactId>org.openhab.addons.reactor.bundles</artifactId>
+ <version>3.1.0-SNAPSHOT</version>
+ </parent>
+
+ <artifactId>org.openhab.binding.bluetooth.govee</artifactId>
+
+ <name>openHAB Add-ons :: Bundles :: Govee Bluetooth Adapter</name>
+
+ <dependencies>
+ <dependency>
+ <groupId>org.openhab.addons.bundles</groupId>
+ <artifactId>org.openhab.binding.bluetooth</artifactId>
+ <version>${project.version}</version>
+ <scope>provided</scope>
+ </dependency>
+
+ <dependency>
+ <groupId>org.openhab.addons.bundles</groupId>
+ <artifactId>org.openhab.binding.bluetooth</artifactId>
+ <version>${project.version}</version>
+ <type>test-jar</type>
+ <scope>test</scope>
+ </dependency>
+
+ </dependencies>
+</project>
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.bluetooth.govee-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
+ <repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
+
+ <feature name="openhab-binding-bluetooth-govee" description="Bluetooth Binding Govee" version="${project.version}">
+ <feature>openhab-runtime-base</feature>
+ <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth/${project.version}</bundle>
+ <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.govee/${project.version}</bundle>
+ </feature>
+</features>
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.gattserial;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public interface GattMessage {
+
+ public byte[] getPayload();
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.gattserial;
+
+import java.util.Deque;
+import java.util.Iterator;
+import java.util.List;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.ConcurrentLinkedDeque;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.function.Consumer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public abstract class GattSocket<T extends GattMessage, R extends GattMessage> {
+
+ private static final Future<?> COMPLETED_FUTURE = CompletableFuture.completedFuture(null);
+
+ private final Deque<MessageProcessor> messageProcessors = new ConcurrentLinkedDeque<>();
+
+ public void registerMessageHandler(MessageHandler<T, R> messageHandler) {
+ // we need to use a dummy future since ConcurrentHashMap doesn't allow null values
+ messageProcessors.addFirst(new MessageProcessor(messageHandler, COMPLETED_FUTURE));
+ }
+
+ protected abstract ScheduledExecutorService getScheduler();
+
+ public void sendMessage(MessageServicer<T, R> messageServicer) {
+ T message = messageServicer.createMessage();
+
+ CompletableFuture<@Nullable Void> messageFuture = sendMessage(message);
+
+ Future<?> timeoutFuture = getScheduler().schedule(() -> {
+ messageFuture.completeExceptionally(new TimeoutException("Timeout while waiting for response"));
+ }, messageServicer.getTimeout(TimeUnit.NANOSECONDS), TimeUnit.NANOSECONDS);
+
+ MessageProcessor processor = new MessageProcessor(messageServicer, timeoutFuture);
+ messageProcessors.addLast(processor);
+
+ messageFuture.whenComplete((v, ex) -> {
+ if (ex instanceof CompletionException) {
+ ex = ex.getCause();
+ }
+ if (ex != null) {
+ if (messageServicer.handleFailedMessage(message, ex)) {
+ timeoutFuture.cancel(false);
+ messageProcessors.remove(processor);
+ }
+ }
+ });
+ }
+
+ public CompletableFuture<@Nullable Void> sendMessage(T message) {
+ List<byte[]> packets = createPackets(message);
+ var futures = packets.stream()//
+ .map(this::sendPacket)//
+ .toArray(CompletableFuture[]::new);
+
+ return CompletableFuture.allOf(futures);
+ }
+
+ protected List<byte[]> createPackets(T message) {
+ return List.of(message.getPayload());
+ }
+
+ protected abstract void parsePacket(byte[] packet, Consumer<R> messageHandler);
+
+ protected abstract CompletableFuture<@Nullable Void> sendPacket(byte[] value);
+
+ public void receivePacket(byte[] packet) {
+ parsePacket(packet, this::handleMessage);
+ }
+
+ private void handleMessage(R message) {
+ for (Iterator<MessageProcessor> it = messageProcessors.iterator(); it.hasNext();) {
+ MessageProcessor processor = it.next();
+ if (processor.messageHandler.handleReceivedMessage(message)) {
+ processor.timeoutFuture.cancel(false);
+ it.remove();
+ // we want to return after the first message servicer handles the message
+ if (processor.timeoutFuture != COMPLETED_FUTURE) {
+ return;
+ }
+ }
+ }
+ }
+
+ private class MessageProcessor {
+ private MessageHandler<T, R> messageHandler;
+ private Future<?> timeoutFuture;
+
+ public MessageProcessor(MessageHandler<T, R> messageHandler, Future<?> timeoutFuture) {
+ this.messageHandler = messageHandler;
+ this.timeoutFuture = timeoutFuture;
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.gattserial;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public interface MessageHandler<T extends GattMessage, R extends GattMessage> {
+
+ /**
+ *
+ * @param payload
+ * @return true if this handler should be removed from the handler list
+ */
+ public boolean handleReceivedMessage(R message);
+
+ /**
+ *
+ * @param payload
+ * @return true if this handler should be removed from the handler list
+ */
+ public boolean handleFailedMessage(T message, Throwable th);
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.gattserial;
+
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public interface MessageServicer<T extends GattMessage, R extends GattMessage>
+ extends MessageHandler<T, R>, MessageSupplier<T> {
+
+ public long getTimeout(TimeUnit unit);
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.gattserial;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+public interface MessageSupplier<M extends GattMessage> {
+
+ public M createMessage();
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.gattserial;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public abstract class SimpleGattSocket<M extends GattMessage> extends GattSocket<M, M> {
+
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.gattserial;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public class SimpleMessage implements GattMessage {
+
+ private byte[] data;
+
+ public SimpleMessage(byte[] data) {
+ this.data = data;
+ }
+
+ @Override
+ public byte[] getPayload() {
+ return data;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.gattserial;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public interface SimpleMessageHandler<M extends GattMessage> extends MessageHandler<M, M> {
+
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.gattserial;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public interface SimpleMessageServicer<M extends GattMessage> extends MessageServicer<M, M> {
+
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal;
+
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.ScheduledThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.locks.Condition;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.bluetooth.BeaconBluetoothHandler;
+import org.openhab.binding.bluetooth.BluetoothCharacteristic;
+import org.openhab.binding.bluetooth.BluetoothCompletionStatus;
+import org.openhab.binding.bluetooth.BluetoothDescriptor;
+import org.openhab.binding.bluetooth.BluetoothDevice.ConnectionState;
+import org.openhab.binding.bluetooth.BluetoothService;
+import org.openhab.binding.bluetooth.notification.BluetoothConnectionStatusNotification;
+import org.openhab.core.common.NamedThreadFactory;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.util.HexUtils;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * This is a base implementation for more specific thing handlers that require constant connection to bluetooth devices.
+ *
+ * @author Kai Kreuzer - Initial contribution and API
+ * @deprecated once CompletableFutures are supported in the actual ConnectedBluetoothHandler, this class can be deleted
+ */
+@Deprecated
+@NonNullByDefault
+public class ConnectedBluetoothHandler extends BeaconBluetoothHandler {
+
+ private final Logger logger = LoggerFactory.getLogger(ConnectedBluetoothHandler.class);
+
+ private final Condition connectionCondition = deviceLock.newCondition();
+ private final Condition serviceDiscoveryCondition = deviceLock.newCondition();
+ private final Condition charCompleteCondition = deviceLock.newCondition();
+
+ private @Nullable Future<?> reconnectJob;
+ private @Nullable Future<?> pendingDisconnect;
+ private @Nullable BluetoothCharacteristic ongoingCharacteristic;
+ private @Nullable BluetoothCompletionStatus completeStatus;
+
+ private boolean connectOnDemand;
+ private int idleDisconnectDelayMs = 1000;
+
+ protected @Nullable ScheduledExecutorService connectionTaskExecutor;
+ private volatile boolean servicesDiscovered;
+
+ public ConnectedBluetoothHandler(Thing thing) {
+ super(thing);
+ }
+
+ @Override
+ public void initialize() {
+
+ // super.initialize adds callbacks that might require the connectionTaskExecutor to be present, so we initialize
+ // the connectionTaskExecutor first
+ ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1,
+ new NamedThreadFactory("bluetooth-connection-" + thing.getThingTypeUID(), true));
+ executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
+ executor.setRemoveOnCancelPolicy(true);
+ connectionTaskExecutor = executor;
+
+ super.initialize();
+
+ connectOnDemand = true;
+
+ Object idleDisconnectDelayRaw = getConfig().get("idleDisconnectDelay");
+ idleDisconnectDelayMs = 1000;
+ if (idleDisconnectDelayRaw instanceof Number) {
+ idleDisconnectDelayMs = ((Number) idleDisconnectDelayRaw).intValue();
+ }
+
+ if (!connectOnDemand) {
+ reconnectJob = executor.scheduleWithFixedDelay(() -> {
+ try {
+ if (device.getConnectionState() != ConnectionState.CONNECTED) {
+ device.connect();
+ // we do not set the Thing status here, because we will anyhow receive a call to
+ // onConnectionStateChange
+ } else {
+ // just in case it was already connected to begin with
+ updateStatus(ThingStatus.ONLINE);
+ if (!servicesDiscovered && !device.discoverServices()) {
+ logger.debug("Error while discovering services");
+ }
+ }
+ } catch (RuntimeException ex) {
+ logger.warn("Unexpected error occurred", ex);
+ }
+ }, 0, 30, TimeUnit.SECONDS);
+ }
+ }
+
+ @Override
+ public void dispose() {
+ cancel(reconnectJob);
+ reconnectJob = null;
+ cancel(pendingDisconnect);
+ pendingDisconnect = null;
+
+ super.dispose();
+
+ shutdown(connectionTaskExecutor);
+ connectionTaskExecutor = null;
+ }
+
+ private static void cancel(@Nullable Future<?> future) {
+ if (future != null) {
+ future.cancel(true);
+ }
+ }
+
+ private void shutdown(@Nullable ScheduledExecutorService executor) {
+ if (executor != null) {
+ executor.shutdownNow();
+ }
+ }
+
+ private ScheduledExecutorService getConnectionTaskExecutor() {
+ var executor = connectionTaskExecutor;
+ if (executor == null) {
+ throw new IllegalStateException("characteristicScheduler has not been initialized");
+ }
+ return executor;
+ }
+
+ private void scheduleDisconnect() {
+ cancel(pendingDisconnect);
+ pendingDisconnect = getConnectionTaskExecutor().schedule(device::disconnect, idleDisconnectDelayMs,
+ TimeUnit.MILLISECONDS);
+ }
+
+ private void connectAndWait() throws ConnectionException, TimeoutException, InterruptedException {
+ if (device.getConnectionState() == ConnectionState.CONNECTED) {
+ return;
+ }
+ if (device.getConnectionState() != ConnectionState.CONNECTING) {
+ if (!device.connect()) {
+ throw new ConnectionException("Failed to start connecting");
+ }
+ }
+ logger.debug("waiting for connection");
+ if (!awaitConnection(1, TimeUnit.SECONDS)) {
+ throw new TimeoutException("Connection attempt timeout.");
+ }
+ logger.debug("connection successful");
+ if (!servicesDiscovered) {
+ logger.debug("discovering services");
+ device.discoverServices();
+ if (!awaitServiceDiscovery(20, TimeUnit.SECONDS)) {
+ throw new TimeoutException("Service discovery timeout");
+ }
+ logger.debug("service discovery successful");
+ }
+ }
+
+ private boolean awaitConnection(long timeout, TimeUnit unit) throws InterruptedException {
+ deviceLock.lock();
+ try {
+ long nanosTimeout = unit.toNanos(timeout);
+ while (device.getConnectionState() != ConnectionState.CONNECTED) {
+ if (nanosTimeout <= 0L) {
+ return false;
+ }
+ nanosTimeout = connectionCondition.awaitNanos(nanosTimeout);
+ }
+ } finally {
+ deviceLock.unlock();
+ }
+ return true;
+ }
+
+ private boolean awaitCharacteristicComplete(long timeout, TimeUnit unit) throws InterruptedException {
+ deviceLock.lock();
+ try {
+ long nanosTimeout = unit.toNanos(timeout);
+ while (ongoingCharacteristic != null) {
+ if (nanosTimeout <= 0L) {
+ return false;
+ }
+ nanosTimeout = charCompleteCondition.awaitNanos(nanosTimeout);
+ }
+ } finally {
+ deviceLock.unlock();
+ }
+ return true;
+ }
+
+ private boolean awaitServiceDiscovery(long timeout, TimeUnit unit) throws InterruptedException {
+ deviceLock.lock();
+ try {
+ long nanosTimeout = unit.toNanos(timeout);
+ while (!servicesDiscovered) {
+ if (nanosTimeout <= 0L) {
+ return false;
+ }
+ nanosTimeout = serviceDiscoveryCondition.awaitNanos(nanosTimeout);
+ }
+ } finally {
+ deviceLock.unlock();
+ }
+ return true;
+ }
+
+ private BluetoothCharacteristic connectAndGetCharacteristic(UUID serviceUUID, UUID characteristicUUID)
+ throws BluetoothException, TimeoutException, InterruptedException {
+ connectAndWait();
+ BluetoothService service = device.getServices(serviceUUID);
+ if (service == null) {
+ throw new BluetoothException("Service with uuid " + serviceUUID + " could not be found");
+ }
+ BluetoothCharacteristic characteristic = service.getCharacteristic(characteristicUUID);
+ if (characteristic == null) {
+ throw new BluetoothException("Characteristic with uuid " + characteristicUUID + " could not be found");
+ }
+ return characteristic;
+ }
+
+ private <T> CompletableFuture<T> executeWithConnection(UUID serviceUUID, UUID characteristicUUID,
+ CallableFunction<BluetoothCharacteristic, T> callable) {
+ CompletableFuture<T> future = new CompletableFuture<>();
+ var executor = connectionTaskExecutor;
+ if (executor != null) {
+ executor.execute(() -> {
+ cancel(pendingDisconnect);
+ try {
+ BluetoothCharacteristic characteristic = connectAndGetCharacteristic(serviceUUID,
+ characteristicUUID);
+ future.complete(callable.call(characteristic));
+ } catch (InterruptedException e) {
+ future.completeExceptionally(e);
+ return;// we don't want to schedule anything if we receive an interrupt
+ } catch (TimeoutException e) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
+ future.completeExceptionally(e);
+ } catch (Exception e) {
+ future.completeExceptionally(e);
+ }
+ if (connectOnDemand) {
+ scheduleDisconnect();
+ }
+ });
+ } else {
+ future.completeExceptionally(new IllegalStateException("characteristicScheduler has not been initialized"));
+ }
+ return future;
+ }
+
+ public CompletableFuture<@Nullable Void> enableNotifications(UUID serviceUUID, UUID characteristicUUID) {
+ return executeWithConnection(serviceUUID, characteristicUUID, characteristic -> {
+ if (!device.enableNotifications(characteristic)) {
+ throw new BluetoothException(
+ "Failed to start notifications for characteristic: " + characteristic.getUuid());
+ }
+ return null;
+ });
+ }
+
+ public CompletableFuture<@Nullable Void> writeCharacteristic(UUID serviceUUID, UUID characteristicUUID, byte[] data,
+ boolean enableNotification) {
+ return executeWithConnection(serviceUUID, characteristicUUID, characteristic -> {
+ if (enableNotification) {
+ if (!device.enableNotifications(characteristic)) {
+ throw new BluetoothException(
+ "Failed to start characteristic notification" + characteristic.getUuid());
+ }
+ }
+ // now block for completion
+ characteristic.setValue(data);
+ ongoingCharacteristic = characteristic;
+ if (!device.writeCharacteristic(characteristic)) {
+ throw new BluetoothException("Failed to start writing characteristic " + characteristic.getUuid());
+ }
+ if (!awaitCharacteristicComplete(1, TimeUnit.SECONDS)) {
+ ongoingCharacteristic = null;
+ throw new TimeoutException(
+ "Timeout waiting for characteristic " + characteristic.getUuid() + " write to finish");
+ }
+ if (completeStatus == BluetoothCompletionStatus.ERROR) {
+ throw new BluetoothException("Failed to write characteristic " + characteristic.getUuid());
+ }
+ logger.debug("Wrote {} to characteristic {} of device {}", HexUtils.bytesToHex(data),
+ characteristic.getUuid(), address);
+ return null;
+ });
+ }
+
+ public CompletableFuture<byte[]> readCharacteristic(UUID serviceUUID, UUID characteristicUUID) {
+ return executeWithConnection(serviceUUID, characteristicUUID, characteristic -> {
+ // now block for completion
+ ongoingCharacteristic = characteristic;
+ if (!device.readCharacteristic(characteristic)) {
+ throw new BluetoothException("Failed to start reading characteristic " + characteristic.getUuid());
+ }
+ if (!awaitCharacteristicComplete(1, TimeUnit.SECONDS)) {
+ ongoingCharacteristic = null;
+ throw new TimeoutException(
+ "Timeout waiting for characteristic " + characteristic.getUuid() + " read to finish");
+ }
+ if (completeStatus == BluetoothCompletionStatus.ERROR) {
+ throw new BluetoothException("Failed to read characteristic " + characteristic.getUuid());
+ }
+ byte[] data = characteristic.getByteValue();
+ logger.debug("Characteristic {} from {} has been read - value {}", characteristic.getUuid(), address,
+ HexUtils.bytesToHex(data));
+ return data;
+ });
+ }
+
+ @Override
+ protected void updateStatusBasedOnRssi(boolean receivedSignal) {
+ // if there is no signal, we can be sure we are OFFLINE, but if there is a signal, we also have to check whether
+ // we are connected.
+ if (receivedSignal) {
+ if (device.getConnectionState() == ConnectionState.CONNECTED) {
+ updateStatus(ThingStatus.ONLINE);
+ } else {
+ if (!connectOnDemand) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Device is not connected.");
+ }
+ }
+ } else {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
+ }
+ }
+
+ @Override
+ public void onConnectionStateChange(BluetoothConnectionStatusNotification connectionNotification) {
+ super.onConnectionStateChange(connectionNotification);
+ switch (connectionNotification.getConnectionState()) {
+ case DISCOVERED:
+ // The device is now known on the Bluetooth network, so we can do something...
+ if (!connectOnDemand) {
+ getConnectionTaskExecutor().submit(() -> {
+ if (device.getConnectionState() != ConnectionState.CONNECTED) {
+ if (!device.connect()) {
+ logger.debug("Error connecting to device after discovery.");
+ }
+ }
+ });
+ }
+ break;
+ case CONNECTED:
+ deviceLock.lock();
+ try {
+ connectionCondition.signal();
+ } finally {
+ deviceLock.unlock();
+ }
+ if (!connectOnDemand) {
+ getConnectionTaskExecutor().submit(() -> {
+ if (!servicesDiscovered && !device.discoverServices()) {
+ logger.debug("Error while discovering services");
+ }
+ });
+ }
+ break;
+ case DISCONNECTED:
+ var future = pendingDisconnect;
+ if (future != null) {
+ future.cancel(false);
+ }
+ if (!connectOnDemand) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
+ }
+ break;
+ default:
+ break;
+ }
+ }
+
+ @Override
+ public void onCharacteristicReadComplete(BluetoothCharacteristic characteristic, BluetoothCompletionStatus status) {
+ super.onCharacteristicReadComplete(characteristic, status);
+ deviceLock.lock();
+ try {
+ if (ongoingCharacteristic != null && ongoingCharacteristic.getUuid().equals(characteristic.getUuid())) {
+ completeStatus = status;
+ ongoingCharacteristic = null;
+ charCompleteCondition.signal();
+ }
+ } finally {
+ deviceLock.unlock();
+ }
+ }
+
+ @Override
+ public void onCharacteristicWriteComplete(BluetoothCharacteristic characteristic,
+ BluetoothCompletionStatus status) {
+ super.onCharacteristicWriteComplete(characteristic, status);
+ deviceLock.lock();
+ try {
+ if (ongoingCharacteristic != null && ongoingCharacteristic.getUuid().equals(characteristic.getUuid())) {
+ completeStatus = status;
+ ongoingCharacteristic = null;
+ charCompleteCondition.signal();
+ }
+ } finally {
+ deviceLock.unlock();
+ }
+ }
+
+ @Override
+ public void onServicesDiscovered() {
+ super.onServicesDiscovered();
+ deviceLock.lock();
+ try {
+ this.servicesDiscovered = true;
+ serviceDiscoveryCondition.signal();
+ } finally {
+ deviceLock.unlock();
+ }
+ logger.debug("Service discovery completed for '{}'", address);
+ }
+
+ @Override
+ public void onCharacteristicUpdate(BluetoothCharacteristic characteristic) {
+ super.onCharacteristicUpdate(characteristic);
+ if (logger.isDebugEnabled()) {
+ logger.debug("Recieved update {} to characteristic {} of device {}",
+ HexUtils.bytesToHex(characteristic.getByteValue()), characteristic.getUuid(), address);
+ }
+ }
+
+ @Override
+ public void onDescriptorUpdate(BluetoothDescriptor descriptor) {
+ super.onDescriptorUpdate(descriptor);
+ if (logger.isDebugEnabled()) {
+ logger.debug("Received update {} to descriptor {} of device {}", HexUtils.bytesToHex(descriptor.getValue()),
+ descriptor.getUuid(), address);
+ }
+ }
+
+ public static class BluetoothException extends Exception {
+
+ public BluetoothException(String message) {
+ super(message);
+ }
+ }
+
+ public static class ConnectionException extends BluetoothException {
+
+ public ConnectionException(String message) {
+ super(message);
+ }
+ }
+
+ @FunctionalInterface
+ public static interface CallableFunction<U, R> {
+ public R call(U arg) throws Exception;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal;
+
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.bluetooth.BluetoothBindingConstants;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * The {@link GoveeBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Connor Petty - Initial contribution
+ */
+@NonNullByDefault
+public class GoveeBindingConstants {
+
+ // List of all Thing Type UIDs
+ public static final ThingTypeUID THING_TYPE_HYGROMETER = new ThingTypeUID(BluetoothBindingConstants.BINDING_ID,
+ "goveeHygrometer");
+ public static final ThingTypeUID THING_TYPE_HYGROMETER_MONITOR = new ThingTypeUID(
+ BluetoothBindingConstants.BINDING_ID, "goveeHygrometerMonitor");
+
+ public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_HYGROMETER,
+ THING_TYPE_HYGROMETER_MONITOR);
+
+ // List of all Channel ids
+ public static final String CHANNEL_ID_BATTERY = "battery";
+ public static final String CHANNEL_ID_TEMPERATURE = "temperature";
+ public static final String CHANNEL_ID_TEMPERATURE_ALARM = "temperatureAlarm";
+ public static final String CHANNEL_ID_HUMIDITY = "humidity";
+ public static final String CHANNEL_ID_HUMIDITY_ALARM = "humidityAlarm";
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal;
+
+import static org.openhab.binding.bluetooth.govee.internal.GoveeBindingConstants.SUPPORTED_THING_TYPES_UIDS;
+
+import java.util.HashMap;
+import java.util.Map;
+import java.util.Set;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.bluetooth.BluetoothBindingConstants;
+import org.openhab.binding.bluetooth.discovery.BluetoothDiscoveryDevice;
+import org.openhab.binding.bluetooth.discovery.BluetoothDiscoveryParticipant;
+import org.openhab.core.config.discovery.DiscoveryResult;
+import org.openhab.core.config.discovery.DiscoveryResultBuilder;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.ThingUID;
+import org.osgi.service.component.annotations.Component;
+
+/**
+ * The {@link GoveeDiscoveryParticipant} handles discovery of Govee bluetooth devices
+ *
+ * @author Connor Petty - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = BluetoothDiscoveryParticipant.class)
+public class GoveeDiscoveryParticipant implements BluetoothDiscoveryParticipant {
+
+ @Override
+ public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
+ return SUPPORTED_THING_TYPES_UIDS;
+ }
+
+ private ThingUID getThingUID(BluetoothDiscoveryDevice device, ThingTypeUID thingTypeUID) {
+ return new ThingUID(thingTypeUID, device.getAdapter().getUID(),
+ device.getAddress().toString().toLowerCase().replace(":", ""));
+ }
+
+ @Override
+ public @Nullable ThingUID getThingUID(BluetoothDiscoveryDevice device) {
+ GoveeModel model = GoveeModel.getGoveeModel(device);
+ if (model != null) {
+ return getThingUID(device, model.getThingTypeUID());
+ }
+ return null;
+ }
+
+ @Override
+ public @Nullable DiscoveryResult createResult(BluetoothDiscoveryDevice device) {
+ GoveeModel model = GoveeModel.getGoveeModel(device);
+ if (model != null) {
+ Map<String, Object> properties = new HashMap<>();
+ properties.put(BluetoothBindingConstants.CONFIGURATION_ADDRESS, device.getAddress().toString());
+ properties.put(Thing.PROPERTY_VENDOR, "Govee");
+ properties.put(Thing.PROPERTY_MODEL_ID, model.name());
+ Integer txPower = device.getTxPower();
+ if (txPower != null) {
+ properties.put(BluetoothBindingConstants.PROPERTY_TXPOWER, Integer.toString(txPower));
+ }
+
+ // Create the discovery result and add to the inbox
+ return DiscoveryResultBuilder.create(getThingUID(device, model.getThingTypeUID()))
+ .withProperties(properties)
+ .withRepresentationProperty(BluetoothBindingConstants.CONFIGURATION_ADDRESS)
+ .withBridge(device.getAdapter().getUID()).withLabel(model.getLabel()).build();
+ }
+ return null;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal;
+
+import static org.openhab.binding.bluetooth.govee.internal.GoveeBindingConstants.*;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingTypeUID;
+import org.openhab.core.thing.binding.BaseThingHandlerFactory;
+import org.openhab.core.thing.binding.ThingHandler;
+import org.openhab.core.thing.binding.ThingHandlerFactory;
+import org.osgi.service.component.annotations.Component;
+
+/**
+ * The {@link GoveeHandlerFactory} is responsible for creating things and thing
+ * handlers.
+ *
+ * @author Connor Petty - Initial contribution
+ */
+@NonNullByDefault
+@Component(configurationPid = "binding.bluetooth.govee", service = ThingHandlerFactory.class)
+public class GoveeHandlerFactory extends BaseThingHandlerFactory {
+
+ @Override
+ public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+ return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+ }
+
+ @Override
+ protected @Nullable ThingHandler createHandler(Thing thing) {
+ ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+
+ if (THING_TYPE_HYGROMETER.equals(thingTypeUID) || THING_TYPE_HYGROMETER_MONITOR.equals(thingTypeUID)) {
+ return new GoveeHygrometerHandler(thing);
+ }
+ return null;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal;
+
+import javax.measure.quantity.Dimensionless;
+import javax.measure.quantity.Temperature;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.WarningSettingsDTO;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.library.unit.Units;
+
+/**
+ * @author Connor Petty - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class GoveeHygrometerConfiguration {
+ public int refreshInterval = 300;
+
+ public @Nullable Double temperatureCalibration;
+ public boolean temperatureWarningAlarm = false;
+ public double temperatureWarningMin;
+ public double temperatureWarningMax;
+
+ public @Nullable Double humidityCalibration;
+ public boolean humidityWarningAlarm = false;
+ public double humidityWarningMin;
+ public double humidityWarningMax;
+
+ public @Nullable QuantityType<Temperature> getTemperatureCalibration() {
+ var temCali = temperatureCalibration;
+ if (temCali != null) {
+ return new QuantityType<>(temCali, SIUnits.CELSIUS);
+ }
+ return null;
+ }
+
+ public @Nullable QuantityType<Dimensionless> getHumidityCalibration() {
+ var humCali = humidityCalibration;
+ if (humCali != null) {
+ return new QuantityType<>(humCali, Units.PERCENT);
+ }
+ return null;
+ }
+
+ public WarningSettingsDTO<Temperature> getTemperatureWarningSettings() {
+ WarningSettingsDTO<Temperature> temWarnSettings = new WarningSettingsDTO<>();
+ temWarnSettings.enableAlarm = OnOffType.from(temperatureWarningAlarm);
+ temWarnSettings.min = new QuantityType<>(temperatureWarningMin, SIUnits.CELSIUS);
+ temWarnSettings.max = new QuantityType<>(temperatureWarningMax, SIUnits.CELSIUS);
+ return temWarnSettings;
+ }
+
+ public WarningSettingsDTO<Dimensionless> getHumidityWarningSettings() {
+ WarningSettingsDTO<Dimensionless> humWarnSettings = new WarningSettingsDTO<>();
+ humWarnSettings.enableAlarm = OnOffType.from(humidityWarningAlarm);
+ humWarnSettings.min = new QuantityType<>(humidityWarningMin, Units.PERCENT);
+ humWarnSettings.max = new QuantityType<>(humidityWarningMax, Units.PERCENT);
+ return humWarnSettings;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal;
+
+import static org.openhab.binding.bluetooth.govee.internal.GoveeBindingConstants.*;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.Map;
+import java.util.UUID;
+import java.util.concurrent.CompletableFuture;
+import java.util.concurrent.CompletionException;
+import java.util.concurrent.Future;
+import java.util.concurrent.ScheduledExecutorService;
+import java.util.concurrent.TimeUnit;
+import java.util.function.Consumer;
+
+import javax.measure.Quantity;
+import javax.measure.quantity.Dimensionless;
+import javax.measure.quantity.Temperature;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.bluetooth.BluetoothCharacteristic;
+import org.openhab.binding.bluetooth.BluetoothDevice.ConnectionState;
+import org.openhab.binding.bluetooth.gattserial.MessageServicer;
+import org.openhab.binding.bluetooth.gattserial.SimpleGattSocket;
+import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetBatteryCommand;
+import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetOrSetHumCaliCommand;
+import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetOrSetHumWarningCommand;
+import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetOrSetTemCaliCommand;
+import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetOrSetTemWarningCommand;
+import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetTemHumCommand;
+import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GoveeMessage;
+import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.TemHumDTO;
+import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.WarningSettingsDTO;
+import org.openhab.binding.bluetooth.notification.BluetoothScanNotification;
+import org.openhab.binding.bluetooth.util.HeritableFuture;
+import org.openhab.binding.bluetooth.util.RetryException;
+import org.openhab.binding.bluetooth.util.RetryFuture;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.ChannelUID;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.openhab.core.types.Command;
+import org.openhab.core.types.RefreshType;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * @author Connor Petty - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class GoveeHygrometerHandler extends ConnectedBluetoothHandler {
+
+ private static final UUID SERVICE_UUID = UUID.fromString("494e5445-4c4c-495f-524f-434b535f4857");
+ private static final UUID PROTOCOL_CHAR_UUID = UUID.fromString("494e5445-4c4c-495f-524f-434b535f2011");
+ private static final UUID KEEP_ALIVE_CHAR_UUID = UUID.fromString("494e5445-4c4c-495f-524f-434b535f2012");
+
+ private static final byte[] SCAN_HEADER = { (byte) 0xFF, (byte) 0x88, (byte) 0xEC };
+
+ private final Logger logger = LoggerFactory.getLogger(GoveeHygrometerHandler.class);
+
+ private final CommandSocket commandSocket = new CommandSocket();
+
+ private GoveeHygrometerConfiguration config = new GoveeHygrometerConfiguration();
+ private GoveeModel model = GoveeModel.H5074;// we use this as our default model
+
+ private CompletableFuture<?> initializeJob = CompletableFuture.completedFuture(null);// initially set to a dummy
+ // future
+ private Future<?> scanJob = CompletableFuture.completedFuture(null);
+ private Future<?> keepAliveJob = CompletableFuture.completedFuture(null);
+
+ public GoveeHygrometerHandler(Thing thing) {
+ super(thing);
+ }
+
+ @Override
+ public void initialize() {
+ super.initialize();
+ config = getConfigAs(GoveeHygrometerConfiguration.class);
+
+ Map<String, String> properties = thing.getProperties();
+ String modelProp = properties.get(Thing.PROPERTY_MODEL_ID);
+ model = GoveeModel.H5074;
+ if (modelProp != null) {
+ try {
+ model = GoveeModel.valueOf(modelProp);
+ } catch (IllegalArgumentException ex) {
+ // ignore
+ }
+ }
+
+ logger.debug("Initializing Govee Hygrometer {} model: {}", address, model);
+ initializeJob = RetryFuture.composeWithRetry(this::createInitSettingsJob, scheduler)//
+ .thenRun(() -> {
+ updateStatus(ThingStatus.ONLINE);
+ });
+ scanJob = scheduler.scheduleWithFixedDelay(() -> {
+ try {
+ if (initializeJob.isDone() && !initializeJob.isCompletedExceptionally()) {
+ logger.debug("refreshing temperature, humidity, and battery");
+ refreshBattery().join();
+ refreshTemperatureAndHumidity().join();
+ connectionTaskExecutor.execute(device::disconnect);
+ updateStatus(ThingStatus.ONLINE);
+ }
+ } catch (RuntimeException ex) {
+ logger.warn("unable to refresh", ex);
+ }
+ }, 0, config.refreshInterval, TimeUnit.SECONDS);
+ keepAliveJob = connectionTaskExecutor.scheduleWithFixedDelay(() -> {
+ if (device.getConnectionState() == ConnectionState.CONNECTED) {
+ try {
+ GoveeMessage message = new GoveeMessage((byte) 0xAA, (byte) 1, null);
+ writeCharacteristic(SERVICE_UUID, KEEP_ALIVE_CHAR_UUID, message.getPayload(), false);
+ } catch (RuntimeException ex) {
+ logger.warn("unable to send keep alive", ex);
+ }
+ }
+ }, 1, 2, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public void dispose() {
+ initializeJob.cancel(false);
+ scanJob.cancel(false);
+ keepAliveJob.cancel(false);
+ super.dispose();
+ }
+
+ private CompletableFuture<@Nullable ?> createInitSettingsJob() {
+
+ logger.debug("Initializing Govee Hygrometer {} settings", address);
+
+ QuantityType<Temperature> temCali = config.getTemperatureCalibration();
+ QuantityType<Dimensionless> humCali = config.getHumidityCalibration();
+ WarningSettingsDTO<Temperature> temWarnSettings = config.getTemperatureWarningSettings();
+ WarningSettingsDTO<Dimensionless> humWarnSettings = config.getHumidityWarningSettings();
+
+ final CompletableFuture<@Nullable ?> parent = new HeritableFuture<>();
+ CompletableFuture<@Nullable ?> future = parent;
+ future.complete(null);
+
+ if (temCali != null) {
+ future = future.thenCompose(v -> {
+ CompletableFuture<@Nullable QuantityType<Temperature>> caliFuture = parent.newIncompleteFuture();
+ commandSocket.sendMessage(new GetOrSetTemCaliCommand(temCali, caliFuture));
+ return caliFuture;
+ });
+ }
+ if (humCali != null) {
+ future = future.thenCompose(v -> {
+ CompletableFuture<@Nullable QuantityType<Dimensionless>> caliFuture = parent.newIncompleteFuture();
+ commandSocket.sendMessage(new GetOrSetHumCaliCommand(humCali, caliFuture));
+ return caliFuture;
+ });
+ }
+ if (model.supportsWarningBroadcast()) {
+ future = future.thenCompose(v -> {
+ CompletableFuture<@Nullable WarningSettingsDTO<Temperature>> temWarnFuture = parent
+ .newIncompleteFuture();
+ commandSocket.sendMessage(new GetOrSetTemWarningCommand(temWarnSettings, temWarnFuture));
+ return temWarnFuture;
+ }).thenCompose(v -> {
+ CompletableFuture<@Nullable WarningSettingsDTO<Dimensionless>> humWarnFuture = parent
+ .newIncompleteFuture();
+ commandSocket.sendMessage(new GetOrSetHumWarningCommand(humWarnSettings, humWarnFuture));
+ return humWarnFuture;
+ });
+ }
+
+ // CompletableFuture.exceptionallyCompose isn't available yet so we have to compose it manually for now.
+ CompletableFuture<@Nullable Void> retFuture = future.newIncompleteFuture();
+ future.whenComplete((v, th) -> {
+ if (th instanceof CompletionException) {
+ th = th.getCause();
+ }
+ if (th instanceof RuntimeException) {
+ updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+ "Failed to initialize device: " + th.getMessage());
+ retFuture.completeExceptionally(th);
+ } else if (th != null) {
+ logger.debug("Failure to initialize device: {}. Retrying in 30 seconds", th.getMessage());
+ retFuture.completeExceptionally(new RetryException(30, TimeUnit.SECONDS));
+ } else {
+ retFuture.complete(null);
+ }
+ });
+ return retFuture;
+ }
+
+ @Override
+ public void handleCommand(ChannelUID channelUID, Command command) {
+ super.handleCommand(channelUID, command);
+
+ switch (channelUID.getId()) {
+ case CHANNEL_ID_BATTERY:
+ if (command == RefreshType.REFRESH) {
+ refreshBattery();
+ }
+ return;
+ case CHANNEL_ID_TEMPERATURE:
+ case CHANNEL_ID_HUMIDITY:
+ if (command == RefreshType.REFRESH) {
+ refreshTemperatureAndHumidity();
+ }
+ return;
+ }
+ }
+
+ private CompletableFuture<@Nullable ?> refreshBattery() {
+ CompletableFuture<@Nullable QuantityType<Dimensionless>> future = new CompletableFuture<>();
+ commandSocket.sendMessage(new GetBatteryCommand(future));
+ future.whenCompleteAsync(this::updateBattery, scheduler);
+ return future;
+ }
+
+ private void updateBattery(@Nullable QuantityType<Dimensionless> result, @Nullable Throwable th) {
+ if (th != null) {
+ logger.debug("Failed to get battery: {}", th.getMessage());
+ }
+ if (result == null) {
+ return;
+ }
+ updateState(CHANNEL_ID_BATTERY, result);
+ }
+
+ private CompletableFuture<@Nullable ?> refreshTemperatureAndHumidity() {
+ CompletableFuture<@Nullable TemHumDTO> future = new CompletableFuture<>();
+ commandSocket.sendMessage(new GetTemHumCommand(future));
+ future.whenCompleteAsync(this::updateTemperatureAndHumidity, scheduler);
+ return future;
+ }
+
+ private void updateTemperatureAndHumidity(@Nullable TemHumDTO result, @Nullable Throwable th) {
+ if (th != null) {
+ logger.debug("Failed to get temperature/humidity: {}", th.getMessage());
+ }
+ if (result == null) {
+ return;
+ }
+ QuantityType<Temperature> tem = result.temperature;
+ QuantityType<Dimensionless> hum = result.humidity;
+ if (tem == null || hum == null) {
+ return;
+ }
+ updateState(CHANNEL_ID_TEMPERATURE, tem);
+ updateState(CHANNEL_ID_HUMIDITY, hum);
+ if (model.supportsWarningBroadcast()) {
+ updateAlarm(CHANNEL_ID_TEMPERATURE_ALARM, tem, config.getTemperatureWarningSettings());
+ updateAlarm(CHANNEL_ID_HUMIDITY_ALARM, hum, config.getHumidityWarningSettings());
+ }
+ }
+
+ private <T extends Quantity<T>> void updateAlarm(String channelName, QuantityType<T> quantity,
+ WarningSettingsDTO<T> settings) {
+ boolean outOfRange = quantity.compareTo(settings.min) < 0 || settings.max.compareTo(quantity) < 0;
+ updateState(channelName, OnOffType.from(outOfRange));
+ }
+
+ private int scanPacketSize() {
+ switch (model) {
+ case B5175:
+ case B5178:
+ return 10;
+ case H5179:
+ return 8;
+ default:
+ return 7;
+ }
+ }
+
+ @Override
+ public void onScanRecordReceived(BluetoothScanNotification scanNotification) {
+ super.onScanRecordReceived(scanNotification);
+ byte[] scanData = scanNotification.getData();
+ int dataPacketSize = scanPacketSize();
+ int recordIndex = indexOfTemHumRecord(scanData);
+ if (recordIndex == -1 || recordIndex + dataPacketSize >= scanData.length) {
+ return;
+ }
+
+ ByteBuffer data = ByteBuffer.wrap(scanData, recordIndex, dataPacketSize);
+
+ short temperature;
+ int humidity;
+ int battery;
+ int wifiLevel = 0;
+
+ switch (model) {
+ default:
+ data.position(2);// we throw this away
+ // fall through
+ case H5072:
+ case H5075:
+ data.order(ByteOrder.BIG_ENDIAN);
+ int l = data.getInt();
+ l = l & 0xFFFFFF;
+
+ boolean positive = (l & 0x800000) == 0;
+ int tem = (short) ((l / 1000) * 10);
+ if (!positive) {
+ tem = -tem;
+ }
+ temperature = (short) tem;
+ humidity = (l % 1000) * 10;
+ battery = data.get();
+ break;
+ case H5179:
+ data.order(ByteOrder.LITTLE_ENDIAN);
+ data.position(3);
+ temperature = data.getShort();
+ humidity = data.getShort();
+ battery = Byte.toUnsignedInt(data.get());
+ break;
+ case H5051:
+ case H5052:
+ case H5071:
+ case H5074:
+ data.order(ByteOrder.LITTLE_ENDIAN);
+ boolean hasWifi = data.get() == 0;
+ temperature = data.getShort();
+ humidity = Short.toUnsignedInt(data.getShort());
+ battery = Byte.toUnsignedInt(data.get());
+ wifiLevel = hasWifi ? Byte.toUnsignedInt(data.get()) : 0;
+ break;
+ }
+ updateTemHumBattery(temperature, humidity, battery, wifiLevel);
+ }
+
+ private static int indexOfTemHumRecord(byte @Nullable [] scanData) {
+ if (scanData == null || scanData.length != 62) {
+ return -1;
+ }
+ int i = 0;
+ while (i < 57) {
+ int recordLength = scanData[i] & 0xFF;
+ if (scanData[i + 1] == SCAN_HEADER[0]//
+ && scanData[i + 2] == SCAN_HEADER[1]//
+ && scanData[i + 3] == SCAN_HEADER[2]) {
+ return i + 4;
+ }
+
+ i += recordLength + 1;
+ }
+ return -1;
+ }
+
+ private void updateTemHumBattery(short tem, int hum, int battery, int wifiLevel) {
+ if (Short.toUnsignedInt(tem) == 0xFFFF || hum == 0xFFFF) {
+ logger.trace("Govee device [{}] received invalid data", this.address);
+ return;
+ }
+
+ logger.debug("Govee device [{}] received broadcast: tem = {}, hum = {}, battery = {}, wifiLevel = {}",
+ this.address, tem, hum, battery, wifiLevel);
+
+ if (tem == 0 && hum == 0 && battery == 0) {
+ logger.trace("Govee device [{}] values are zero", this.address);
+ return;
+ }
+ if (tem < -4000 || tem > 10000) {
+ logger.trace("Govee device [{}] invalid temperature value: {}", this.address, tem);
+ return;
+ }
+ if (hum > 10000) {
+ logger.trace("Govee device [{}] invalid humidity valie: {}", this.address, hum);
+ return;
+ }
+
+ TemHumDTO temhum = new TemHumDTO();
+ temhum.temperature = new QuantityType<>(tem / 100.0, SIUnits.CELSIUS);
+ temhum.humidity = new QuantityType<>(hum / 100.0, Units.PERCENT);
+ updateTemperatureAndHumidity(temhum, null);
+
+ updateBattery(new QuantityType<>(battery, Units.PERCENT), null);
+ }
+
+ @Override
+ public void onCharacteristicUpdate(BluetoothCharacteristic characteristic) {
+ super.onCharacteristicUpdate(characteristic);
+ commandSocket.receivePacket(characteristic.getByteValue());
+ }
+
+ private class CommandSocket extends SimpleGattSocket<GoveeMessage> {
+
+ @Override
+ protected ScheduledExecutorService getScheduler() {
+ return scheduler;
+ }
+
+ @Override
+ public void sendMessage(MessageServicer<GoveeMessage, GoveeMessage> messageServicer) {
+ logger.debug("sending message: {}", messageServicer.getClass().getSimpleName());
+ super.sendMessage(messageServicer);
+ }
+
+ @Override
+ protected void parsePacket(byte[] packet, Consumer<GoveeMessage> messageHandler) {
+ messageHandler.accept(new GoveeMessage(packet));
+ }
+
+ @Override
+ protected CompletableFuture<@Nullable Void> sendPacket(byte[] data) {
+ return writeCharacteristic(SERVICE_UUID, PROTOCOL_CHAR_UUID, data, true);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal;
+
+import static org.openhab.binding.bluetooth.govee.internal.GoveeBindingConstants.*;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.bluetooth.discovery.BluetoothDiscoveryDevice;
+import org.openhab.core.thing.ThingTypeUID;
+
+/**
+ * @author Connor Petty - Initial contribution
+ *
+ */
+@NonNullByDefault
+public enum GoveeModel {
+ H5051(THING_TYPE_HYGROMETER, "Govee Wi-Fi Temperature Humidity Monitor", false),
+ H5052(THING_TYPE_HYGROMETER_MONITOR, "Govee Temperature Humidity Monitor", true),
+ H5071(THING_TYPE_HYGROMETER, "Govee Temperature Humidity Monitor", false),
+ H5072(THING_TYPE_HYGROMETER_MONITOR, "Govee Temperature Humidity Monitor", true),
+ H5074(THING_TYPE_HYGROMETER_MONITOR, "Govee Mini Temperature Humidity Monitor", true),
+ H5075(THING_TYPE_HYGROMETER_MONITOR, "Govee Temperature Humidity Monitor", true),
+ H5101(THING_TYPE_HYGROMETER_MONITOR, "Govee Smart Thermo-Hygrometer", true),
+ H5102(THING_TYPE_HYGROMETER_MONITOR, "Govee Smart Thermo-Hygrometer", true),
+ H5177(THING_TYPE_HYGROMETER_MONITOR, "Govee Smart Thermo-Hygrometer", true),
+ H5179(THING_TYPE_HYGROMETER_MONITOR, "Govee Smart Thermo-Hygrometer", true),
+ B5175(THING_TYPE_HYGROMETER_MONITOR, "Govee Smart Thermo-Hygrometer", true),
+ B5178(THING_TYPE_HYGROMETER_MONITOR, "Govee Smart Thermo-Hygrometer", true);
+
+ private final ThingTypeUID thingTypeUID;
+ private final String label;
+ private final boolean supportsWarningBroadcast;
+
+ private GoveeModel(ThingTypeUID thingTypeUID, String label, boolean supportsWarningBroadcast) {
+ this.thingTypeUID = thingTypeUID;
+ this.label = label;
+ this.supportsWarningBroadcast = supportsWarningBroadcast;
+ }
+
+ public ThingTypeUID getThingTypeUID() {
+ return thingTypeUID;
+ }
+
+ public String getLabel() {
+ return label;
+ }
+
+ public boolean supportsWarningBroadcast() {
+ return supportsWarningBroadcast;
+ }
+
+ public static @Nullable GoveeModel getGoveeModel(BluetoothDiscoveryDevice device) {
+ String name = device.getName();
+ if (name != null) {
+ if (name.startsWith("Govee") && name.length() >= 11) {
+ String uname = name.toUpperCase();
+ for (GoveeModel model : GoveeModel.values()) {
+ if (uname.contains(model.name())) {
+ return model;
+ }
+ }
+ }
+ }
+ return null;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
+
+import java.util.concurrent.CompletableFuture;
+
+import javax.measure.quantity.Dimensionless;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public class GetBatteryCommand extends GetCommand {
+
+ private CompletableFuture<@Nullable QuantityType<Dimensionless>> resultHandler;
+
+ public GetBatteryCommand(CompletableFuture<@Nullable QuantityType<Dimensionless>> resultHandler) {
+ this.resultHandler = resultHandler;
+ }
+
+ @Override
+ public byte getCommandCode() {
+ return 8;
+ }
+
+ @Override
+ public void handleResponse(byte @Nullable [] data, @Nullable Throwable th) {
+ if (th != null) {
+ resultHandler.completeExceptionally(th);
+ }
+ if (data != null) {
+ int value = data[0] & 0xFF;
+ resultHandler.complete(new QuantityType<Dimensionless>(value, Units.PERCENT));
+ } else {
+ resultHandler.complete(null);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public abstract class GetCommand extends GoveeCommand {
+
+ @Override
+ public byte getCommandType() {
+ return READ_TYPE;
+ }
+
+ @Override
+ protected byte @Nullable [] getData() {
+ return null;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.concurrent.CompletableFuture;
+
+import javax.measure.quantity.Dimensionless;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public class GetOrSetHumCaliCommand extends GoveeCommand {
+
+ private final CompletableFuture<@Nullable QuantityType<Dimensionless>> resultHandler;
+ private final @Nullable QuantityType<Dimensionless> value;
+
+ public GetOrSetHumCaliCommand(CompletableFuture<@Nullable QuantityType<Dimensionless>> resultHandler) {
+ this.value = null;
+ this.resultHandler = resultHandler;
+ }
+
+ public GetOrSetHumCaliCommand(QuantityType<Dimensionless> value,
+ CompletableFuture<@Nullable QuantityType<Dimensionless>> resultHandler) {
+ this.value = value;
+ this.resultHandler = resultHandler;
+ }
+
+ @Override
+ public byte getCommandType() {
+ return value != null ? WRITE_TYPE : READ_TYPE;
+ }
+
+ @Override
+ public byte getCommandCode() {
+ return 6;
+ }
+
+ private static short convertQuantity(QuantityType<Dimensionless> quantity) {
+ var percentQuantity = quantity.toUnit(Units.PERCENT);
+ if (percentQuantity == null) {
+ throw new IllegalArgumentException("Unable to convert quantity to percent");
+ }
+ return (short) (percentQuantity.doubleValue() * 100);
+ }
+
+ @Override
+ protected byte @Nullable [] getData() {
+ var v = value;
+ if (v != null) {
+ return ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(convertQuantity(v)).array();
+ }
+ return null;
+ }
+
+ @Override
+ public void handleResponse(byte @Nullable [] data, @Nullable Throwable th) {
+ if (th != null) {
+ resultHandler.completeExceptionally(th);
+ }
+ if (data != null) {
+ short hum = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).getShort();
+ resultHandler.complete(new QuantityType<>(hum / 100.0, Units.PERCENT));
+ } else {
+ resultHandler.complete(null);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.concurrent.CompletableFuture;
+
+import javax.measure.quantity.Dimensionless;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.Units;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public class GetOrSetHumWarningCommand extends GoveeCommand {
+
+ private final @Nullable WarningSettingsDTO<Dimensionless> settings;
+ private final CompletableFuture<@Nullable WarningSettingsDTO<Dimensionless>> resultHandler;
+
+ public GetOrSetHumWarningCommand(CompletableFuture<@Nullable WarningSettingsDTO<Dimensionless>> resultHandler) {
+ this.settings = null;
+ this.resultHandler = resultHandler;
+ }
+
+ public GetOrSetHumWarningCommand(WarningSettingsDTO<Dimensionless> settings,
+ CompletableFuture<@Nullable WarningSettingsDTO<Dimensionless>> resultHandler) {
+ this.settings = settings;
+ this.resultHandler = resultHandler;
+ }
+
+ @Override
+ public byte getCommandType() {
+ return settings == null ? READ_TYPE : WRITE_TYPE;
+ }
+
+ @Override
+ public byte getCommandCode() {
+ return 3;
+ }
+
+ private static short convertQuantity(QuantityType<Dimensionless> quantity) {
+ var percentQuantity = quantity.toUnit(Units.PERCENT);
+ if (percentQuantity == null) {
+ throw new IllegalArgumentException("Unable to convert quantity to percent");
+ }
+ return (short) (percentQuantity.doubleValue() * 100);
+ }
+
+ @Override
+ protected byte @Nullable [] getData() {
+ if (settings == null) {
+ return null;
+ }
+
+ ByteBuffer buffer = ByteBuffer.allocate(5).order(ByteOrder.LITTLE_ENDIAN);
+ buffer.put(settings.enableAlarm == OnOffType.ON ? (byte) 1 : 0);
+ buffer.putShort(convertQuantity(settings.min));
+ buffer.putShort(convertQuantity(settings.max));
+ return buffer.array();
+ }
+
+ @Override
+ public void handleResponse(byte @Nullable [] data, @Nullable Throwable th) {
+ if (th != null) {
+ resultHandler.completeExceptionally(th);
+ }
+ if (data != null) {
+ WarningSettingsDTO<Dimensionless> result = new WarningSettingsDTO<Dimensionless>();
+
+ ByteBuffer buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN);
+ result.enableAlarm = OnOffType.from(buffer.get() == 1);
+ result.min = new QuantityType<Dimensionless>(buffer.getShort() / 100.0, Units.PERCENT);
+ result.max = new QuantityType<Dimensionless>(buffer.getShort() / 100.0, Units.PERCENT);
+
+ resultHandler.complete(result);
+ } else {
+ resultHandler.complete(null);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.concurrent.CompletableFuture;
+
+import javax.measure.quantity.Temperature;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.SIUnits;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public class GetOrSetTemCaliCommand extends GoveeCommand {
+ private final CompletableFuture<@Nullable QuantityType<Temperature>> resultHandler;
+ private final @Nullable QuantityType<Temperature> value;
+
+ public GetOrSetTemCaliCommand(CompletableFuture<@Nullable QuantityType<Temperature>> resultHandler) {
+ this.value = null;
+ this.resultHandler = resultHandler;
+ }
+
+ public GetOrSetTemCaliCommand(QuantityType<Temperature> value,
+ CompletableFuture<@Nullable QuantityType<Temperature>> resultHandler) {
+ this.value = value;
+ this.resultHandler = resultHandler;
+ }
+
+ @Override
+ public byte getCommandType() {
+ return value != null ? WRITE_TYPE : READ_TYPE;
+ }
+
+ @Override
+ public byte getCommandCode() {
+ return 7;
+ }
+
+ private static short convertQuantity(QuantityType<Temperature> quantity) {
+ var celciusQuantity = quantity.toUnit(SIUnits.CELSIUS);
+ if (celciusQuantity == null) {
+ throw new IllegalArgumentException("Unable to convert quantity to celcius");
+ }
+ return (short) (celciusQuantity.doubleValue() * 100);
+ }
+
+ @Override
+ protected byte @Nullable [] getData() {
+ var v = value;
+ if (v != null) {
+ return ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(convertQuantity(v)).array();
+ }
+ return null;
+ }
+
+ @Override
+ public void handleResponse(byte @Nullable [] data, @Nullable Throwable th) {
+ if (th != null) {
+ resultHandler.completeExceptionally(th);
+ }
+ if (data != null) {
+ short tem = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).getShort();
+ resultHandler.complete(new QuantityType<>(tem / 100.0, SIUnits.CELSIUS));
+ } else {
+ resultHandler.complete(null);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.concurrent.CompletableFuture;
+
+import javax.measure.quantity.Temperature;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.SIUnits;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public class GetOrSetTemWarningCommand extends GoveeCommand {
+ private final @Nullable WarningSettingsDTO<Temperature> settings;
+ private final CompletableFuture<@Nullable WarningSettingsDTO<Temperature>> resultHandler;
+
+ public GetOrSetTemWarningCommand(CompletableFuture<@Nullable WarningSettingsDTO<Temperature>> resultHandler) {
+ this.settings = null;
+ this.resultHandler = resultHandler;
+ }
+
+ public GetOrSetTemWarningCommand(WarningSettingsDTO<Temperature> settings,
+ CompletableFuture<@Nullable WarningSettingsDTO<Temperature>> resultHandler) {
+ this.settings = settings;
+ this.resultHandler = resultHandler;
+ }
+
+ @Override
+ public byte getCommandType() {
+ return settings == null ? READ_TYPE : WRITE_TYPE;
+ }
+
+ @Override
+ public byte getCommandCode() {
+ return 4;
+ }
+
+ private static short convertQuantity(QuantityType<Temperature> quantity) {
+ var celciusQuantity = quantity.toUnit(SIUnits.CELSIUS);
+ if (celciusQuantity == null) {
+ throw new IllegalArgumentException("Unable to convert quantity to celcius");
+ }
+ return (short) (celciusQuantity.doubleValue() * 100);
+ }
+
+ @Override
+ protected byte @Nullable [] getData() {
+ var settings = this.settings;
+ if (settings == null) {
+ return null;
+ }
+ ByteBuffer buffer = ByteBuffer.allocate(5).order(ByteOrder.LITTLE_ENDIAN);
+ buffer.put(settings.enableAlarm == OnOffType.ON ? (byte) 1 : 0);
+ buffer.putShort(convertQuantity(settings.min));
+ buffer.putShort(convertQuantity(settings.max));
+ return buffer.array();
+ }
+
+ @Override
+ public void handleResponse(byte @Nullable [] data, @Nullable Throwable th) {
+ if (th != null) {
+ resultHandler.completeExceptionally(th);
+ }
+ if (data != null) {
+ WarningSettingsDTO<Temperature> result = new WarningSettingsDTO<Temperature>();
+
+ ByteBuffer buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN);
+ result.enableAlarm = OnOffType.from(buffer.get() == 1);
+ result.min = new QuantityType<Temperature>(buffer.getShort() / 100.0, SIUnits.CELSIUS);
+ result.max = new QuantityType<Temperature>(buffer.getShort() / 100.0, SIUnits.CELSIUS);
+
+ resultHandler.complete(result);
+ } else {
+ resultHandler.complete(null);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
+
+import java.nio.ByteBuffer;
+import java.nio.ByteOrder;
+import java.util.concurrent.CompletableFuture;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.library.unit.Units;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public class GetTemHumCommand extends GetCommand {
+
+ private CompletableFuture<@Nullable TemHumDTO> resultHandler;
+
+ public GetTemHumCommand(CompletableFuture<@Nullable TemHumDTO> resultHandler) {
+ this.resultHandler = resultHandler;
+ }
+
+ @Override
+ public byte getCommandCode() {
+ return 10;
+ }
+
+ @Override
+ public void handleResponse(byte @Nullable [] data, @Nullable Throwable th) {
+ if (th != null) {
+ resultHandler.completeExceptionally(th);
+ }
+ if (data != null) {
+ ByteBuffer buffer = ByteBuffer.wrap(data);
+ buffer.order(ByteOrder.LITTLE_ENDIAN);
+ int temp = buffer.getShort();
+ int hum = Short.toUnsignedInt(buffer.getShort());
+
+ TemHumDTO temhum = new TemHumDTO();
+ temhum.temperature = new QuantityType<>(temp / 100.0, SIUnits.CELSIUS);
+ temhum.humidity = new QuantityType<>(hum / 100.0, Units.PERCENT);
+ resultHandler.complete(temhum);
+ } else {
+ resultHandler.complete(null);
+ }
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
+
+import java.util.concurrent.TimeUnit;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.bluetooth.gattserial.SimpleMessageServicer;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public abstract class GoveeCommand implements SimpleMessageServicer<GoveeMessage> {
+
+ public static final byte READ_TYPE = -86;
+ public static final byte WRITE_TYPE = 51;
+
+ public abstract byte getCommandType();
+
+ public abstract byte getCommandCode();
+
+ protected abstract byte @Nullable [] getData();
+
+ @Override
+ public long getTimeout(TimeUnit unit) {
+ return unit.convert(60, TimeUnit.SECONDS);
+ }
+
+ @Override
+ public GoveeMessage createMessage() {
+ return new GoveeMessage(getCommandType(), getCommandCode(), getData());
+ }
+
+ @Override
+ public boolean handleFailedMessage(GoveeMessage message, Throwable th) {
+ if (matches(message)) {
+ handleResponse(null, th);
+ return true;
+ }
+ return false;
+ }
+
+ @Override
+ public boolean handleReceivedMessage(GoveeMessage message) {
+ if (matches(message)) {
+ handleResponse(message.getData(), null);
+ return true;
+ }
+ return false;
+ }
+
+ public abstract void handleResponse(byte @Nullable [] data, @Nullable Throwable th);
+
+ protected boolean matches(GoveeMessage message) {
+ return message.getCommandType() == getCommandType() && message.getCommandCode() == getCommandCode();
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.eclipse.jdt.annotation.Nullable;
+import org.openhab.binding.bluetooth.gattserial.GattMessage;
+
+/**
+ * @author Connor Petty - Initial contribution
+ *
+ */
+@NonNullByDefault
+public class GoveeMessage implements GattMessage {
+
+ private byte[] payload;
+
+ public GoveeMessage(byte[] payload) {
+ this.payload = payload;
+ }
+
+ public GoveeMessage(byte commandType, byte commandCode, byte @Nullable [] data) {
+ payload = new byte[20];
+ payload[0] = commandType;
+ payload[1] = commandCode;
+ if (data != null) {
+ System.arraycopy(data, 0, payload, 2, data.length);
+ }
+ payload[19] = calculateCrc(payload, 19);
+ }
+
+ public byte getCommandType() {
+ return payload[0];
+ }
+
+ public byte getCommandCode() {
+ return payload[1];
+ }
+
+ protected static byte calculateCrc(byte[] bArr, int i) {
+ byte b = bArr[0];
+ for (int i2 = 1; i2 < i; i2++) {
+ b = (byte) (b ^ bArr[i2]);
+ }
+ return b;
+ }
+
+ public byte @Nullable [] getData() {
+ byte[] data = new byte[17];
+ System.arraycopy(payload, 2, data, 0, Math.min(payload.length - 2, 17));
+ return data;
+ }
+
+ @Override
+ public byte[] getPayload() {
+ return payload;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * @author Connor Petty - Initial Contribution
+ *
+ */
+@NonNullByDefault
+public abstract class SetCommand extends GoveeCommand {
+
+ @Override
+ public byte getCommandType() {
+ return WRITE_TYPE;
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
+
+import javax.measure.quantity.Dimensionless;
+import javax.measure.quantity.Temperature;
+
+import org.eclipse.jdt.annotation.NonNull;
+import org.openhab.core.library.types.QuantityType;
+
+/**
+ * @author Connor Petty - Initial contribution
+ *
+ */
+public class TemHumDTO {
+ public QuantityType<@NonNull Temperature> temperature;
+ public QuantityType<@NonNull Dimensionless> humidity;
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
+
+import javax.measure.Quantity;
+
+import org.openhab.core.library.types.OnOffType;
+import org.openhab.core.library.types.QuantityType;
+
+/**
+ * @author Connor Petty - Initial contribution
+ *
+ */
+public class WarningSettingsDTO<Q extends Quantity<Q>> {
+ public OnOffType enableAlarm;
+ public QuantityType<Q> min;
+ public QuantityType<Q> max;
+}
--- /dev/null
+<?xml version="1.0" encoding="UTF-8"?>
+<thing:thing-descriptions bindingId="bluetooth"
+ xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+ xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
+ xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
+
+
+ <thing-type id="goveeHygrometer">
+ <supported-bridge-type-refs>
+ <bridge-type-ref id="roaming"/>
+ <bridge-type-ref id="bluegiga"/>
+ <bridge-type-ref id="bluez"/>
+ </supported-bridge-type-refs>
+
+ <label>Govee Hygrometer</label>
+ <description>Govee Thermo-Hygrometer</description>
+
+ <channels>
+ <channel id="rssi" typeId="rssi"/>
+ <channel id="battery" typeId="system.battery-level"/>
+
+ <channel id="temperature" typeId="govee-temperature"/>
+ <channel id="humidity" typeId="system.atmospheric-humidity"/>
+ </channels>
+
+ <representation-property>address</representation-property>
+
+ <config-description>
+ <parameter-group name="calibration">
+ <label>Calibration</label>
+ <description>Sensor calibration settings.</description>
+ <advanced>true</advanced>
+ </parameter-group>
+
+ <parameter name="address" type="text" required="true">
+ <label>Address</label>
+ <description>Bluetooth address in XX:XX:XX:XX:XX:XX format</description>
+ </parameter>
+ <parameter name="refreshInterval" type="integer" unit="s" required="true">
+ <label>Refresh Interval</label>
+ <description>The frequency at which battery, temperature, and humidity data will refresh</description>
+ <default>300</default>
+ <advanced>true</advanced>
+ </parameter>
+
+ <parameter name="temperatureCalibration" type="decimal" min="-1.6" max="1.6" groupName="calibration"
+ unit="Cel">
+ <label>Temperature Calibration</label>
+ <description>Adds offset to reported temperature</description>
+ <advanced>true</advanced>
+ </parameter>
+ <parameter name="humidityCalibration" type="decimal" min="-9" max="9" groupName="calibration" unit="%">
+ <label>Humidity Calibration</label>
+ <description>Adds offset to reported humidity</description>
+ <advanced>true</advanced>
+ </parameter>
+ </config-description>
+ </thing-type>
+
+ <thing-type id="goveeHygrometerMonitor">
+ <supported-bridge-type-refs>
+ <bridge-type-ref id="roaming"/>
+ <bridge-type-ref id="bluegiga"/>
+ <bridge-type-ref id="bluez"/>
+ </supported-bridge-type-refs>
+
+ <label>Govee Monitoring Hygrometer</label>
+ <description>Govee Thermo-Hygrometer w/ Warning Alarms</description>
+
+ <channels>
+ <channel id="rssi" typeId="rssi"/>
+ <channel id="battery" typeId="system.battery-level"/>
+
+ <channel id="temperature" typeId="govee-temperature"/>
+ <channel id="temperatureAlarm" typeId="govee-temperature-alarm"/>
+
+ <channel id="humidity" typeId="system.atmospheric-humidity"/>
+ <channel id="humidityAlarm" typeId="govee-humidity-alarm"/>
+
+ </channels>
+
+ <representation-property>address</representation-property>
+
+ <config-description>
+ <parameter-group name="calibration">
+ <label>Calibration</label>
+ <description>Sensor calibration settings.</description>
+ <advanced>true</advanced>
+ </parameter-group>
+ <parameter-group name="alarms">
+ <label>Alarm</label>
+ <description>Alarm settings.</description>
+ <advanced>true</advanced>
+ </parameter-group>
+
+
+ <parameter name="address" type="text" required="true">
+ <label>Address</label>
+ <description>Bluetooth address in XX:XX:XX:XX:XX:XX format</description>
+ </parameter>
+ <parameter name="refreshInterval" type="integer" unit="s" required="true">
+ <label>Refresh Interval</label>
+ <description>The frequency at which battery, temperature, and humidity data will refresh</description>
+ <default>300</default>
+ <advanced>true</advanced>
+ </parameter>
+
+ <parameter name="temperatureCalibration" type="decimal" min="-1.6" max="1.6" groupName="calibration"
+ unit="Cel">
+ <label>Temperature Calibration</label>
+ <description>Adds offset to reported temperature</description>
+ <advanced>true</advanced>
+ </parameter>
+ <parameter name="temperatureWarningAlarm" type="boolean" groupName="alarms" required="true">
+ <label>Broadcast Temperature Warning</label>
+ <description>If enabled, the Govee device will notify openHAB if temperature is out of the specified range</description>
+ <default>false</default>
+ <advanced>true</advanced>
+ </parameter>
+ <parameter name="temperatureWarningMin" type="decimal" min="-20" max="60" step="0.2" groupName="alarms"
+ unit="Cel" required="true">
+ <label>Min Warning Temperature</label>
+ <description>Sets the lowest acceptable temperature value before a warning should be issued</description>
+ <default>0</default>
+ <advanced>true</advanced>
+ </parameter>
+ <parameter name="temperatureWarningMax" type="decimal" min="-20" max="60" step="0.2" groupName="alarms"
+ unit="Cel" required="true">
+ <label>Max Warning Temperature</label>
+ <description>Sets the highest acceptable temperature value before a warning should be issued</description>
+ <default>0</default>
+ <advanced>true</advanced>
+ </parameter>
+
+ <parameter name="humidityCalibration" type="decimal" min="-9" max="9" groupName="calibration" unit="%">
+ <label>Humidity Calibration</label>
+ <description>Adds offset to reported humidity</description>
+ <advanced>true</advanced>
+ </parameter>
+ <parameter name="humidityWarningAlarm" type="boolean" groupName="alarms" required="true">
+ <label>Broadcast Humidity Warning</label>
+ <description>If enabled, the Govee device will notify openHAB if humidity is out of the specified range</description>
+ <default>false</default>
+ <advanced>true</advanced>
+ </parameter>
+ <parameter name="humidityWarningMin" type="decimal" min="0" max="100" step="0.1" groupName="alarms"
+ unit="%" required="true">
+ <label>Min Warning Humidity</label>
+ <description>Sets the lowest acceptable humidity value before a warning should be issued</description>
+ <default>0</default>
+ <advanced>true</advanced>
+ </parameter>
+ <parameter name="humidityWarningMax" type="decimal" min="0" max="100" step="0.1" groupName="alarms"
+ unit="%" required="true">
+ <label>Max Warning Humidity</label>
+ <description>Sets the highest acceptable humidity value before a warning should be issued</description>
+ <default>0</default>
+ <advanced>true</advanced>
+ </parameter>
+ </config-description>
+
+ </thing-type>
+
+ <channel-type id="govee-temperature">
+ <item-type>Number:Temperature</item-type>
+ <label>Current Measured Temperature</label>
+ <category>Temperature</category>
+ <state readOnly="true" pattern="%.1f %unit%"/>
+ </channel-type>
+
+ <channel-type id="govee-temperature-alarm">
+ <item-type>Switch</item-type>
+ <label>Temperature Warning Alarm</label>
+ <description>
+ If temperature warnings are enabled, then this alarm indicates whether the current temperature is out of
+ range.
+ </description>
+ <category>Alarm</category>
+ </channel-type>
+
+ <channel-type id="govee-humidity-alarm">
+ <item-type>Switch</item-type>
+ <label>Humidity Warning Alarm</label>
+ <description>
+ If humidity warnings are enabled, then this alarm indicates whether the current humidity is out of range.
+ </description>
+ <category>Alarm</category>
+ </channel-type>
+
+</thing:thing-descriptions>
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.bluetooth.MockBluetoothAdapter;
+import org.openhab.binding.bluetooth.MockBluetoothDevice;
+import org.openhab.binding.bluetooth.TestUtils;
+import org.openhab.binding.bluetooth.discovery.BluetoothDiscoveryDevice;
+
+/**
+ * @author Connor Petty - Initial contribution
+ *
+ */
+@NonNullByDefault
+class GoveeModelTest {
+
+ // the participant is stateless so this is fine.
+ // private GoveeDiscoveryParticipant participant = new GoveeDiscoveryParticipant();
+
+ @Test
+ void noMatchTest() {
+ MockBluetoothAdapter adapter = new MockBluetoothAdapter();
+ MockBluetoothDevice mockDevice = adapter.getDevice(TestUtils.randomAddress());
+ mockDevice.setName("asdfasdf");
+
+ Assertions.assertNull(GoveeModel.getGoveeModel(new BluetoothDiscoveryDevice(mockDevice)));
+ }
+
+ @Test
+ void testGovee_H5074_84DD() {
+ MockBluetoothAdapter adapter = new MockBluetoothAdapter();
+ MockBluetoothDevice mockDevice = adapter.getDevice(TestUtils.randomAddress());
+ mockDevice.setName("Govee_H5074_84DD");
+
+ Assertions.assertEquals(GoveeModel.H5074, GoveeModel.getGoveeModel(new BluetoothDiscoveryDevice(mockDevice)));
+ }
+}
--- /dev/null
+/**
+ * Copyright (c) 2010-2021 Contributors to the openHAB project
+ *
+ * See the NOTICE file(s) distributed with this work for additional
+ * information.
+ *
+ * This program and the accompanying materials are made available under the
+ * terms of the Eclipse Public License 2.0 which is available at
+ * http://www.eclipse.org/legal/epl-2.0
+ *
+ * SPDX-License-Identifier: EPL-2.0
+ */
+package org.openhab.binding.bluetooth.govee.internal.readme;
+
+import java.io.FileInputStream;
+import java.io.StringWriter;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.List;
+import java.util.stream.Collectors;
+import java.util.stream.Stream;
+
+import javax.xml.parsers.DocumentBuilder;
+import javax.xml.parsers.DocumentBuilderFactory;
+import javax.xml.xpath.XPath;
+import javax.xml.xpath.XPathConstants;
+import javax.xml.xpath.XPathExpression;
+import javax.xml.xpath.XPathFactory;
+
+import org.openhab.binding.bluetooth.govee.internal.GoveeModel;
+import org.w3c.dom.Document;
+import org.w3c.dom.Node;
+import org.w3c.dom.NodeList;
+
+/**
+ * @author Connor Petty - Initial contribution
+ *
+ */
+public class ThingTypeTableGenerator {
+
+ public static void main(String[] args) throws Exception {
+
+ FileInputStream fileIS = new FileInputStream("src/main/resources/OH-INF/thing/thing-types.xml");
+ DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
+ DocumentBuilder builder = builderFactory.newDocumentBuilder();
+ Document xmlDocument = builder.parse(fileIS);
+ XPath xPath = XPathFactory.newInstance().newXPath();
+ String expression = "/*[local-name()='thing-descriptions']/thing-type";
+ XPathExpression labelExpression = xPath.compile("label/text()");
+ XPathExpression descriptionExpression = xPath.compile("description/text()");
+
+ NodeList nodeList = (NodeList) xPath.compile(expression).evaluate(xmlDocument, XPathConstants.NODESET);
+
+ List<ThingTypeData> thingTypeDataList = new ArrayList<>();
+
+ for (int i = 0; i < nodeList.getLength(); i++) {
+ Node node = nodeList.item(i);
+ ThingTypeData data = new ThingTypeData();
+
+ data.id = node.getAttributes().getNamedItem("id").getTextContent();
+ data.label = (String) labelExpression.evaluate(node, XPathConstants.STRING);
+ data.description = (String) descriptionExpression.evaluate(node, XPathConstants.STRING);
+
+ thingTypeDataList.add(data);
+ }
+
+ String[] headerRow = new String[] { "Thing Type ID", "Description", "Supported Models" };
+
+ List<String[]> rows = new ArrayList<>();
+ rows.add(headerRow);
+ rows.addAll(thingTypeDataList.stream().map(ThingTypeTableGenerator::toRow).collect(Collectors.toList()));
+
+ int[] maxColumns = { maxColumnSize(rows, 0), maxColumnSize(rows, 1), maxColumnSize(rows, 2) };
+
+ StringWriter writer = new StringWriter();
+
+ // write actual rows
+ rows.forEach(row -> {
+ writer.append(writeRow(maxColumns, row, ' ')).append('\n');
+ if (row == headerRow) {
+ writer.append(writeRow(maxColumns, new String[] { "", "", "" }, '-')).append('\n');
+ }
+ });
+
+ System.out.println(writer.toString());
+ }
+
+ private static String writeRow(int[] maxColumns, String[] row, char paddingChar) {
+ String prefix = "|" + paddingChar;
+ String infix = paddingChar + "|" + paddingChar;
+ String suffix = paddingChar + "|";
+
+ return Stream.of(0, 1, 2).map(i -> rightPad(row[i], maxColumns[i], paddingChar))
+ .collect(Collectors.joining(infix, prefix, suffix));
+ }
+
+ private static String rightPad(String str, int minLength, char paddingChar) {
+ if (str.length() >= minLength) {
+ return str;
+ }
+ StringBuilder builder = new StringBuilder(minLength);
+ builder.append(str);
+ while (builder.length() < minLength) {
+ builder.append(paddingChar);
+ }
+ return builder.toString();
+ }
+
+ private static int maxColumnSize(List<String[]> rows, int column) {
+ return rows.stream().map(row -> row[column].length()).max(Integer::compare).get();
+ }
+
+ private static class ThingTypeData {
+ private String id;
+ private String label;
+ private String description;
+ }
+
+ private static String[] toRow(ThingTypeData data) {
+ return new String[] { data.id, //
+ data.description, //
+ modelsForType(data.id).stream().map(model -> model.name()).collect(Collectors.joining(",")) };
+ }
+
+ private static List<GoveeModel> modelsForType(String typeUID) {
+ return Arrays.stream(GoveeModel.values()).filter(model -> model.getThingTypeUID().getId().equals(typeUID))
+ .collect(Collectors.toList());
+ }
+}
<name>openHAB Add-ons :: Bundles :: Bluetooth Binding</name>
+ <build>
+ <plugins>
+ <plugin>
+ <groupId>org.apache.maven.plugins</groupId>
+ <artifactId>maven-jar-plugin</artifactId>
+ <executions>
+ <execution>
+ <goals>
+ <goal>test-jar</goal>
+ </goals>
+ </execution>
+ </executions>
+ </plugin>
+ </plugins>
+ </build>
</project>
<module>org.openhab.binding.bluetooth.daikinmadoka</module>
<module>org.openhab.binding.bluetooth.enoceanble</module>
<module>org.openhab.binding.bluetooth.generic</module>
+ <module>org.openhab.binding.bluetooth.govee</module>
<module>org.openhab.binding.bluetooth.roaming</module>
<module>org.openhab.binding.bluetooth.ruuvitag</module>
<module>org.openhab.binding.boschindego</module>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.daikinmadoka/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.enoceanble/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.generic/${project.version}</bundle>
+ <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.govee/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.roaming/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.ruuvitag/${project.version}</bundle>
</feature>