From 239e33af2674fddf0260aa3a2b18fd62ea788b81 Mon Sep 17 00:00:00 2001 From: Connor Petty Date: Sun, 24 Jan 2021 23:44:03 -0800 Subject: [PATCH] [bluetooth.govee] Govee Bluetooth Binding initial contribution (#8610) Signed-off-by: Connor Petty --- CODEOWNERS | 1 + bom/openhab-addons/pom.xml | 5 + .../NOTICE | 13 + .../README.md | 68 +++ .../pom.xml | 34 ++ .../src/main/feature/feature.xml | 10 + .../bluetooth/gattserial/GattMessage.java | 25 + .../bluetooth/gattserial/GattSocket.java | 117 +++++ .../bluetooth/gattserial/MessageHandler.java | 37 ++ .../bluetooth/gattserial/MessageServicer.java | 28 ++ .../bluetooth/gattserial/MessageSupplier.java | 22 + .../gattserial/SimpleGattSocket.java | 24 + .../bluetooth/gattserial/SimpleMessage.java | 34 ++ .../gattserial/SimpleMessageHandler.java | 24 + .../gattserial/SimpleMessageServicer.java | 24 + .../internal/ConnectedBluetoothHandler.java | 472 ++++++++++++++++++ .../govee/internal/GoveeBindingConstants.java | 45 ++ .../internal/GoveeDiscoveryParticipant.java | 82 +++ .../govee/internal/GoveeHandlerFactory.java | 50 ++ .../GoveeHygrometerConfiguration.java | 75 +++ .../internal/GoveeHygrometerHandler.java | 424 ++++++++++++++++ .../bluetooth/govee/internal/GoveeModel.java | 77 +++ .../command/hygrometer/GetBatteryCommand.java | 54 ++ .../command/hygrometer/GetCommand.java | 34 ++ .../hygrometer/GetOrSetHumCaliCommand.java | 86 ++++ .../hygrometer/GetOrSetHumWarningCommand.java | 97 ++++ .../hygrometer/GetOrSetTemCaliCommand.java | 85 ++++ .../hygrometer/GetOrSetTemWarningCommand.java | 96 ++++ .../command/hygrometer/GetTemHumCommand.java | 62 +++ .../command/hygrometer/GoveeCommand.java | 70 +++ .../command/hygrometer/GoveeMessage.java | 68 +++ .../command/hygrometer/SetCommand.java | 28 ++ .../command/hygrometer/TemHumDTO.java | 28 ++ .../hygrometer/WarningSettingsDTO.java | 28 ++ .../resources/OH-INF/thing/thing-types.xml | 190 +++++++ .../govee/internal/GoveeModelTest.java | 50 ++ .../readme/ThingTypeTableGenerator.java | 129 +++++ bundles/org.openhab.binding.bluetooth/pom.xml | 15 + bundles/pom.xml | 1 + .../src/main/resources/footer.xml | 1 + 40 files changed, 2813 insertions(+) create mode 100644 bundles/org.openhab.binding.bluetooth.govee/NOTICE create mode 100644 bundles/org.openhab.binding.bluetooth.govee/README.md create mode 100644 bundles/org.openhab.binding.bluetooth.govee/pom.xml create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/feature/feature.xml create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/GattMessage.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/GattSocket.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/MessageHandler.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/MessageServicer.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/MessageSupplier.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleGattSocket.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleMessage.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleMessageHandler.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleMessageServicer.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/ConnectedBluetoothHandler.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeBindingConstants.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeDiscoveryParticipant.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeHandlerFactory.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeHygrometerConfiguration.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeHygrometerHandler.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeModel.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetBatteryCommand.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetCommand.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetHumCaliCommand.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetHumWarningCommand.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetTemCaliCommand.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetTemWarningCommand.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetTemHumCommand.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GoveeCommand.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GoveeMessage.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/SetCommand.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/TemHumDTO.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/WarningSettingsDTO.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/main/resources/OH-INF/thing/thing-types.xml create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/test/java/org/openhab/binding/bluetooth/govee/internal/GoveeModelTest.java create mode 100644 bundles/org.openhab.binding.bluetooth.govee/src/test/java/org/openhab/binding/bluetooth/govee/internal/readme/ThingTypeTableGenerator.java diff --git a/CODEOWNERS b/CODEOWNERS index adc1b1474d3..c4aa2073cdd 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -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 diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index d418a1638e8..95becddb6ca 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -146,6 +146,11 @@ org.openhab.binding.bluetooth.generic ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.bluetooth.govee + ${project.version} + org.openhab.addons.bundles org.openhab.binding.bluetooth.roaming diff --git a/bundles/org.openhab.binding.bluetooth.govee/NOTICE b/bundles/org.openhab.binding.bluetooth.govee/NOTICE new file mode 100644 index 00000000000..38d625e3492 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/NOTICE @@ -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 index 00000000000..6d9e1aeedba --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/README.md @@ -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 | +| goveeHygrometer1 | temperatureCalibration | no | | Offset to apply to temperature2 sensor readings | +| goveeHygrometer1 | 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 temperature2 threshold 3 | +| goveeHygrometerMonitor | temperatureWarningMax | | 0 | The upper safe temperature2 threshold 3 | +| goveeHygrometerMonitor | humidityWarningAlarm | | false | Enables warning alarms to be broadcast when humidity is out of specified range | +| goveeHygrometerMonitor | humidityWarningMin | | 0 | The lower safe humidity threshold 3 | +| goveeHygrometerMonitor | humidityWarningMax | | 0 | The upper safe humidity threshold 3 | + +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 | +|-----------------------------|------------------|------------------------|----------------------------------------------------------------| +| goveeHygrometer1 | temperature | Number:Temperature | The measured temperature | +| goveeHygrometer1 | humidity | Number:Dimensionless | The measured relative humidity | +| goveeHygrometer1 | battery | Number:Dimensionless | The measured battery percentage | +| goveeHygrometerMonitor | temperatureAlarm | Switch | Indicates if current temperature is out of range. 2 | +| goveeHygrometerMonitor | humidityAlarm | Switch | Indicates if current humidity is out of range. 2 | + +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 index 00000000000..34140815923 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/pom.xml @@ -0,0 +1,34 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 3.1.0-SNAPSHOT + + + org.openhab.binding.bluetooth.govee + + openHAB Add-ons :: Bundles :: Govee Bluetooth Adapter + + + + org.openhab.addons.bundles + org.openhab.binding.bluetooth + ${project.version} + provided + + + + org.openhab.addons.bundles + org.openhab.binding.bluetooth + ${project.version} + test-jar + test + + + + 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 index 00000000000..41d596d81ab --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/feature/feature.xml @@ -0,0 +1,10 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth/${project.version} + mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.govee/${project.version} + + 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 index 00000000000..bf76316aa04 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/GattMessage.java @@ -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 index 00000000000..af61f2ee9dc --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/GattSocket.java @@ -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 { + + private static final Future COMPLETED_FUTURE = CompletableFuture.completedFuture(null); + + private final Deque messageProcessors = new ConcurrentLinkedDeque<>(); + + public void registerMessageHandler(MessageHandler 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 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 packets = createPackets(message); + var futures = packets.stream()// + .map(this::sendPacket)// + .toArray(CompletableFuture[]::new); + + return CompletableFuture.allOf(futures); + } + + protected List createPackets(T message) { + return List.of(message.getPayload()); + } + + protected abstract void parsePacket(byte[] packet, Consumer 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 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 messageHandler; + private Future timeoutFuture; + + public MessageProcessor(MessageHandler 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 index 00000000000..bf1e9b205a2 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/MessageHandler.java @@ -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 { + + /** + * + * @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 index 00000000000..aef5c9f39ce --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/MessageServicer.java @@ -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 + extends MessageHandler, MessageSupplier { + + 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 index 00000000000..e9d3a1b71d6 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/MessageSupplier.java @@ -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 { + + 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 index 00000000000..9ef33ff0b43 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleGattSocket.java @@ -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 extends GattSocket { + +} 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 index 00000000000..fcc014061ff --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleMessage.java @@ -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 index 00000000000..04f69812dc1 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleMessageHandler.java @@ -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 extends MessageHandler { + +} 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 index 00000000000..8efa287daf9 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/gattserial/SimpleMessageServicer.java @@ -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 extends MessageServicer { + +} 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 index 00000000000..a6f3bdbe69a --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/ConnectedBluetoothHandler.java @@ -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 CompletableFuture executeWithConnection(UUID serviceUUID, UUID characteristicUUID, + CallableFunction callable) { + CompletableFuture 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 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 { + 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 index 00000000000..eee8bc2bdea --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeBindingConstants.java @@ -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 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 index 00000000000..132f4e5851b --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeDiscoveryParticipant.java @@ -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 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 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 index 00000000000..81359f79795 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeHandlerFactory.java @@ -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 index 00000000000..8da759e8065 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeHygrometerConfiguration.java @@ -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 getTemperatureCalibration() { + var temCali = temperatureCalibration; + if (temCali != null) { + return new QuantityType<>(temCali, SIUnits.CELSIUS); + } + return null; + } + + public @Nullable QuantityType getHumidityCalibration() { + var humCali = humidityCalibration; + if (humCali != null) { + return new QuantityType<>(humCali, Units.PERCENT); + } + return null; + } + + public WarningSettingsDTO getTemperatureWarningSettings() { + WarningSettingsDTO 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 getHumidityWarningSettings() { + WarningSettingsDTO 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 index 00000000000..21caa88cfff --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeHygrometerHandler.java @@ -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 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 temCali = config.getTemperatureCalibration(); + QuantityType humCali = config.getHumidityCalibration(); + WarningSettingsDTO temWarnSettings = config.getTemperatureWarningSettings(); + WarningSettingsDTO 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> caliFuture = parent.newIncompleteFuture(); + commandSocket.sendMessage(new GetOrSetTemCaliCommand(temCali, caliFuture)); + return caliFuture; + }); + } + if (humCali != null) { + future = future.thenCompose(v -> { + CompletableFuture<@Nullable QuantityType> caliFuture = parent.newIncompleteFuture(); + commandSocket.sendMessage(new GetOrSetHumCaliCommand(humCali, caliFuture)); + return caliFuture; + }); + } + if (model.supportsWarningBroadcast()) { + future = future.thenCompose(v -> { + CompletableFuture<@Nullable WarningSettingsDTO> temWarnFuture = parent + .newIncompleteFuture(); + commandSocket.sendMessage(new GetOrSetTemWarningCommand(temWarnSettings, temWarnFuture)); + return temWarnFuture; + }).thenCompose(v -> { + CompletableFuture<@Nullable WarningSettingsDTO> 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> future = new CompletableFuture<>(); + commandSocket.sendMessage(new GetBatteryCommand(future)); + future.whenCompleteAsync(this::updateBattery, scheduler); + return future; + } + + private void updateBattery(@Nullable QuantityType 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 tem = result.temperature; + QuantityType 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 > void updateAlarm(String channelName, QuantityType quantity, + WarningSettingsDTO 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 { + + @Override + protected ScheduledExecutorService getScheduler() { + return scheduler; + } + + @Override + public void sendMessage(MessageServicer messageServicer) { + logger.debug("sending message: {}", messageServicer.getClass().getSimpleName()); + super.sendMessage(messageServicer); + } + + @Override + protected void parsePacket(byte[] packet, Consumer 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 index 00000000000..f0830c9953f --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/GoveeModel.java @@ -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 index 00000000000..279c3205d05 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetBatteryCommand.java @@ -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> resultHandler; + + public GetBatteryCommand(CompletableFuture<@Nullable QuantityType> 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(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 index 00000000000..f83ea5cd886 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetCommand.java @@ -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 index 00000000000..1c097d9a884 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetHumCaliCommand.java @@ -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> resultHandler; + private final @Nullable QuantityType value; + + public GetOrSetHumCaliCommand(CompletableFuture<@Nullable QuantityType> resultHandler) { + this.value = null; + this.resultHandler = resultHandler; + } + + public GetOrSetHumCaliCommand(QuantityType value, + CompletableFuture<@Nullable QuantityType> 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 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 index 00000000000..9e71e88acce --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetHumWarningCommand.java @@ -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 settings; + private final CompletableFuture<@Nullable WarningSettingsDTO> resultHandler; + + public GetOrSetHumWarningCommand(CompletableFuture<@Nullable WarningSettingsDTO> resultHandler) { + this.settings = null; + this.resultHandler = resultHandler; + } + + public GetOrSetHumWarningCommand(WarningSettingsDTO settings, + CompletableFuture<@Nullable WarningSettingsDTO> 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 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 result = new WarningSettingsDTO(); + + ByteBuffer buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN); + result.enableAlarm = OnOffType.from(buffer.get() == 1); + result.min = new QuantityType(buffer.getShort() / 100.0, Units.PERCENT); + result.max = new QuantityType(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 index 00000000000..cc1a00fda98 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetTemCaliCommand.java @@ -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> resultHandler; + private final @Nullable QuantityType value; + + public GetOrSetTemCaliCommand(CompletableFuture<@Nullable QuantityType> resultHandler) { + this.value = null; + this.resultHandler = resultHandler; + } + + public GetOrSetTemCaliCommand(QuantityType value, + CompletableFuture<@Nullable QuantityType> 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 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 index 00000000000..c3ad828d656 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetOrSetTemWarningCommand.java @@ -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 settings; + private final CompletableFuture<@Nullable WarningSettingsDTO> resultHandler; + + public GetOrSetTemWarningCommand(CompletableFuture<@Nullable WarningSettingsDTO> resultHandler) { + this.settings = null; + this.resultHandler = resultHandler; + } + + public GetOrSetTemWarningCommand(WarningSettingsDTO settings, + CompletableFuture<@Nullable WarningSettingsDTO> 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 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 result = new WarningSettingsDTO(); + + ByteBuffer buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN); + result.enableAlarm = OnOffType.from(buffer.get() == 1); + result.min = new QuantityType(buffer.getShort() / 100.0, SIUnits.CELSIUS); + result.max = new QuantityType(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 index 00000000000..198e4a92d94 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GetTemHumCommand.java @@ -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 index 00000000000..60f2657f03a --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GoveeCommand.java @@ -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 { + + 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 index 00000000000..653d2ee53a3 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/GoveeMessage.java @@ -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 index 00000000000..7db69011234 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/SetCommand.java @@ -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 index 00000000000..c8d768295f1 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/TemHumDTO.java @@ -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 index 00000000000..225e30b5828 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/java/org/openhab/binding/bluetooth/govee/internal/command/hygrometer/WarningSettingsDTO.java @@ -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> { + public OnOffType enableAlarm; + public QuantityType min; + public QuantityType 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 index 00000000000..302deaaf0e0 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/main/resources/OH-INF/thing/thing-types.xml @@ -0,0 +1,190 @@ + + + + + + + + + + + + + Govee Thermo-Hygrometer + + + + + + + + + + address + + + + + Sensor calibration settings. + true + + + + + Bluetooth address in XX:XX:XX:XX:XX:XX format + + + + The frequency at which battery, temperature, and humidity data will refresh + 300 + true + + + + + Adds offset to reported temperature + true + + + + Adds offset to reported humidity + true + + + + + + + + + + + + + Govee Thermo-Hygrometer w/ Warning Alarms + + + + + + + + + + + + + + address + + + + + Sensor calibration settings. + true + + + + Alarm settings. + true + + + + + + Bluetooth address in XX:XX:XX:XX:XX:XX format + + + + The frequency at which battery, temperature, and humidity data will refresh + 300 + true + + + + + Adds offset to reported temperature + true + + + + If enabled, the Govee device will notify openHAB if temperature is out of the specified range + false + true + + + + Sets the lowest acceptable temperature value before a warning should be issued + 0 + true + + + + Sets the highest acceptable temperature value before a warning should be issued + 0 + true + + + + + Adds offset to reported humidity + true + + + + If enabled, the Govee device will notify openHAB if humidity is out of the specified range + false + true + + + + Sets the lowest acceptable humidity value before a warning should be issued + 0 + true + + + + Sets the highest acceptable humidity value before a warning should be issued + 0 + true + + + + + + + Number:Temperature + + Temperature + + + + + Switch + + + If temperature warnings are enabled, then this alarm indicates whether the current temperature is out of + range. + + Alarm + + + + Switch + + + If humidity warnings are enabled, then this alarm indicates whether the current humidity is out of range. + + Alarm + + + 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 index 00000000000..cb5fb72404f --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/test/java/org/openhab/binding/bluetooth/govee/internal/GoveeModelTest.java @@ -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 index 00000000000..f28c07e2792 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.govee/src/test/java/org/openhab/binding/bluetooth/govee/internal/readme/ThingTypeTableGenerator.java @@ -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 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 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 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 modelsForType(String typeUID) { + return Arrays.stream(GoveeModel.values()).filter(model -> model.getThingTypeUID().getId().equals(typeUID)) + .collect(Collectors.toList()); + } +} diff --git a/bundles/org.openhab.binding.bluetooth/pom.xml b/bundles/org.openhab.binding.bluetooth/pom.xml index 253614e865c..559d6a82b82 100644 --- a/bundles/org.openhab.binding.bluetooth/pom.xml +++ b/bundles/org.openhab.binding.bluetooth/pom.xml @@ -14,4 +14,19 @@ openHAB Add-ons :: Bundles :: Bluetooth Binding + + + + org.apache.maven.plugins + maven-jar-plugin + + + + test-jar + + + + + + diff --git a/bundles/pom.xml b/bundles/pom.xml index 13f4e56e17d..5aac7b0963b 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -63,6 +63,7 @@ org.openhab.binding.bluetooth.daikinmadoka org.openhab.binding.bluetooth.enoceanble org.openhab.binding.bluetooth.generic + org.openhab.binding.bluetooth.govee org.openhab.binding.bluetooth.roaming org.openhab.binding.bluetooth.ruuvitag org.openhab.binding.boschindego diff --git a/features/openhab-addons/src/main/resources/footer.xml b/features/openhab-addons/src/main/resources/footer.xml index e99c26921b7..0385fa97249 100644 --- a/features/openhab-addons/src/main/resources/footer.xml +++ b/features/openhab-addons/src/main/resources/footer.xml @@ -12,6 +12,7 @@ mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.daikinmadoka/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.enoceanble/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.generic/${project.version} + mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.govee/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.roaming/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.ruuvitag/${project.version}