]> git.basschouten.com Git - openhab-addons.git/commitdiff
[bluetooth.govee] Govee Bluetooth Binding initial contribution (#8610)
authorConnor Petty <mistercpp2000@gmail.com>
Mon, 25 Jan 2021 07:44:03 +0000 (23:44 -0800)
committerGitHub <noreply@github.com>
Mon, 25 Jan 2021 07:44:03 +0000 (08:44 +0100)
Signed-off-by: Connor Petty <mistercpp2000+gitsignoff@gmail.com>
40 files changed:
CODEOWNERS
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.bluetooth.govee/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/README.md [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/GattMessage.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/GattSocket.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/MessageHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/MessageServicer.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/MessageSupplier.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleGattSocket.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleMessage.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleMessageHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleMessageServicer.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/ConnectedBluetoothHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeDiscoveryParticipant.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeHygrometerConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeHygrometerHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeModel.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetBatteryCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetHumCaliCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetHumWarningCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetTemCaliCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetTemWarningCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetTemHumCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GoveeCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GoveeMessage.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/SetCommand.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/TemHumDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/WarningSettingsDTO.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/main/resources/OH-INF/thing/thing-types.xml [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/test/java/org/openhab/binding/bluetooth/govee/internal/GoveeModelTest.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.govee/src/test/java/org/openhab/binding/bluetooth/govee/internal/readme/ThingTypeTableGenerator.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth/pom.xml
bundles/pom.xml
features/openhab-addons/src/main/resources/footer.xml

index adc1b1474d378a59b464b949af02eda92bc9637a..c4aa2073cdd3eb43375ed172b141a8076e1c92a9 100644 (file)
@@ -32,6 +32,7 @@
 /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
index d418a1638e82c03a0d03ddd0694ef8088061b5be..95becddb6ca41aae4be0b72bde45c7122288403a 100644 (file)
       <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>
diff --git a/bundles/org.openhab.binding.bluetooth.govee/NOTICE b/bundles/org.openhab.binding.bluetooth.govee/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -0,0 +1,13 @@
+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
diff --git a/bundles/org.openhab.binding.bluetooth.govee/README.md b/bundles/org.openhab.binding.bluetooth.govee/README.md
new file mode 100644 (file)
index 0000000..6d9e1ae
--- /dev/null
@@ -0,0 +1,68 @@
+# 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" }
+```
diff --git a/bundles/org.openhab.binding.bluetooth.govee/pom.xml b/bundles/org.openhab.binding.bluetooth.govee/pom.xml
new file mode 100644 (file)
index 0000000..3414081
--- /dev/null
@@ -0,0 +1,34 @@
+<?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>
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/feature/feature.xml b/bundles/org.openhab.binding.bluetooth.govee/src/main/feature/feature.xml
new file mode 100644 (file)
index 0000000..41d596d
--- /dev/null
@@ -0,0 +1,10 @@
+<?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>
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/GattMessage.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/GattMessage.java
new file mode 100644 (file)
index 0000000..bf76316
--- /dev/null
@@ -0,0 +1,25 @@
+/**
+ * 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();
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/GattSocket.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/GattSocket.java
new file mode 100644 (file)
index 0000000..af61f2e
--- /dev/null
@@ -0,0 +1,117 @@
+/**
+ * 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;
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/MessageHandler.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/MessageHandler.java
new file mode 100644 (file)
index 0000000..bf1e9b2
--- /dev/null
@@ -0,0 +1,37 @@
+/**
+ * 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);
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/MessageServicer.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/MessageServicer.java
new file mode 100644 (file)
index 0000000..aef5c9f
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * 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);
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/MessageSupplier.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/MessageSupplier.java
new file mode 100644 (file)
index 0000000..e9d3a1b
--- /dev/null
@@ -0,0 +1,22 @@
+/**
+ * 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();
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleGattSocket.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleGattSocket.java
new file mode 100644 (file)
index 0000000..9ef33ff
--- /dev/null
@@ -0,0 +1,24 @@
+/**
+ * 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> {
+
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleMessage.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleMessage.java
new file mode 100644 (file)
index 0000000..fcc0140
--- /dev/null
@@ -0,0 +1,34 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleMessageHandler.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleMessageHandler.java
new file mode 100644 (file)
index 0000000..04f6981
--- /dev/null
@@ -0,0 +1,24 @@
+/**
+ * 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> {
+
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleMessageServicer.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleMessageServicer.java
new file mode 100644 (file)
index 0000000..8efa287
--- /dev/null
@@ -0,0 +1,24 @@
+/**
+ * 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> {
+
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/ConnectedBluetoothHandler.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/ConnectedBluetoothHandler.java
new file mode 100644 (file)
index 0000000..a6f3bdb
--- /dev/null
@@ -0,0 +1,472 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeBindingConstants.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeBindingConstants.java
new file mode 100644 (file)
index 0000000..eee8bc2
--- /dev/null
@@ -0,0 +1,45 @@
+/**
+ * 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";
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeDiscoveryParticipant.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeDiscoveryParticipant.java
new file mode 100644 (file)
index 0000000..132f4e5
--- /dev/null
@@ -0,0 +1,82 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeHandlerFactory.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeHandlerFactory.java
new file mode 100644 (file)
index 0000000..81359f7
--- /dev/null
@@ -0,0 +1,50 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeHygrometerConfiguration.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeHygrometerConfiguration.java
new file mode 100644 (file)
index 0000000..8da759e
--- /dev/null
@@ -0,0 +1,75 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeHygrometerHandler.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeHygrometerHandler.java
new file mode 100644 (file)
index 0000000..21caa88
--- /dev/null
@@ -0,0 +1,424 @@
+/**
+ * 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);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeModel.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeModel.java
new file mode 100644 (file)
index 0000000..f0830c9
--- /dev/null
@@ -0,0 +1,77 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetBatteryCommand.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetBatteryCommand.java
new file mode 100644 (file)
index 0000000..279c320
--- /dev/null
@@ -0,0 +1,54 @@
+/**
+ * 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);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetCommand.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetCommand.java
new file mode 100644 (file)
index 0000000..f83ea5c
--- /dev/null
@@ -0,0 +1,34 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetHumCaliCommand.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetHumCaliCommand.java
new file mode 100644 (file)
index 0000000..1c097d9
--- /dev/null
@@ -0,0 +1,86 @@
+/**
+ * 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);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetHumWarningCommand.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetHumWarningCommand.java
new file mode 100644 (file)
index 0000000..9e71e88
--- /dev/null
@@ -0,0 +1,97 @@
+/**
+ * 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);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetTemCaliCommand.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetTemCaliCommand.java
new file mode 100644 (file)
index 0000000..cc1a00f
--- /dev/null
@@ -0,0 +1,85 @@
+/**
+ * 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);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetTemWarningCommand.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetTemWarningCommand.java
new file mode 100644 (file)
index 0000000..c3ad828
--- /dev/null
@@ -0,0 +1,96 @@
+/**
+ * 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);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetTemHumCommand.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetTemHumCommand.java
new file mode 100644 (file)
index 0000000..198e4a9
--- /dev/null
@@ -0,0 +1,62 @@
+/**
+ * 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);
+        }
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GoveeCommand.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GoveeCommand.java
new file mode 100644 (file)
index 0000000..60f2657
--- /dev/null
@@ -0,0 +1,70 @@
+/**
+ * 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();
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GoveeMessage.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GoveeMessage.java
new file mode 100644 (file)
index 0000000..653d2ee
--- /dev/null
@@ -0,0 +1,68 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/SetCommand.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/SetCommand.java
new file mode 100644 (file)
index 0000000..7db6901
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * 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;
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/TemHumDTO.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/TemHumDTO.java
new file mode 100644 (file)
index 0000000..c8d7682
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * 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;
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/WarningSettingsDTO.java b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/WarningSettingsDTO.java
new file mode 100644 (file)
index 0000000..225e30b
--- /dev/null
@@ -0,0 +1,28 @@
+/**
+ * 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;
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.bluetooth.govee/src/main/resources/OH-INF/thing/thing-types.xml
new file mode 100644 (file)
index 0000000..302deaa
--- /dev/null
@@ -0,0 +1,190 @@
+<?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>
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/test/java/org/openhab/binding/bluetooth/govee/internal/GoveeModelTest.java b/bundles/org.openhab.binding.bluetooth.govee/src/test/java/org/openhab/binding/bluetooth/govee/internal/GoveeModelTest.java
new file mode 100644 (file)
index 0000000..cb5fb72
--- /dev/null
@@ -0,0 +1,50 @@
+/**
+ * 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)));
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.govee/src/test/java/org/openhab/binding/bluetooth/govee/internal/readme/ThingTypeTableGenerator.java b/bundles/org.openhab.binding.bluetooth.govee/src/test/java/org/openhab/binding/bluetooth/govee/internal/readme/ThingTypeTableGenerator.java
new file mode 100644 (file)
index 0000000..f28c07e
--- /dev/null
@@ -0,0 +1,129 @@
+/**
+ * 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());
+    }
+}
index 253614e865cb9b0b733731c4c68ee69ed6b14c49..559d6a82b821d4f87f0f42a0e68b8fa7652d45e3 100644 (file)
 
   <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>
index 13f4e56e17d3f543eee127f5fbc60d8965c8972e..5aac7b0963bd2f9efc06accfb508214b1ce2ca98 100644 (file)
@@ -63,6 +63,7 @@
     <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>
index e99c26921b7d840f2b6a5a1ab80de628c3c089ee..0385fa97249df05d13b8ee4d3ced74892a627176 100644 (file)
@@ -12,6 +12,7 @@
                <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>