[bluetooth.govee] Govee Bluetooth Binding initial contribution (#8610)

Signed-off-by: Connor Petty <mistercpp2000+gitsignoff@gmail.com>
This commit is contained in:
Connor Petty 2021-01-24 23:44:03 -08:00 committed by GitHub
parent f5ee685556
commit 239e33af26
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
40 changed files with 2813 additions and 0 deletions

View File

@ -32,6 +32,7 @@
/bundles/org.openhab.binding.bluetooth.daikinmadoka/ @blafois /bundles/org.openhab.binding.bluetooth.daikinmadoka/ @blafois
/bundles/org.openhab.binding.bluetooth.enoceanble/ @pfink /bundles/org.openhab.binding.bluetooth.enoceanble/ @pfink
/bundles/org.openhab.binding.bluetooth.generic/ @cpmeister /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.roaming/ @cpmeister
/bundles/org.openhab.binding.bluetooth.ruuvitag/ @ssalonen /bundles/org.openhab.binding.bluetooth.ruuvitag/ @ssalonen
/bundles/org.openhab.binding.boschindego/ @jofleck /bundles/org.openhab.binding.boschindego/ @jofleck

View File

@ -146,6 +146,11 @@
<artifactId>org.openhab.binding.bluetooth.generic</artifactId> <artifactId>org.openhab.binding.bluetooth.generic</artifactId>
<version>${project.version}</version> <version>${project.version}</version>
</dependency> </dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.bluetooth.govee</artifactId>
<version>${project.version}</version>
</dependency>
<dependency> <dependency>
<groupId>org.openhab.addons.bundles</groupId> <groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.bluetooth.roaming</artifactId> <artifactId>org.openhab.binding.bluetooth.roaming</artifactId>

View File

@ -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

View File

@ -0,0 +1,68 @@
# Govee
This extension adds support for [Govee](https://www.govee.com/) Bluetooth Devices.
## Supported Things
Only two thing types are supported by this extension at the moment.
| Thing Type ID | Description | Supported Models |
|------------------------|-------------------------------------------|-------------------------------------------------------------|
| goveeHygrometer | Govee Thermo-Hygrometer | H5051,H5071 |
| goveeHygrometerMonitor | Govee Thermo-Hygrometer w/ Warning Alarms | H5052,H5072,H5074,H5075,H5101,H5102,H5177,H5179,B5175,B5178 |
## Discovery
As any other Bluetooth device, Govee devices are discovered automatically by the corresponding bridge.
## Thing Configuration
Govee things have the following configuration parameters:
| Thing | Parameter | Required | Default | Description |
|-----------------------------|-------------------------|----------|---------|-----------------------------------------------------------------------------------|
| all | address | yes | | The Bluetooth address of the device (in format "XX:XX:XX:XX:XX:XX") |
| all | refreshInterval | | 300 | How often, in seconds, the sensor data of the device should be refreshed |
| goveeHygrometer<sup>1</sup> | temperatureCalibration | no | | Offset to apply to temperature<sup>2</sup> sensor readings |
| goveeHygrometer<sup>1</sup> | humidityCalibration | no | | Offset to apply to humidity sensor readings |
| goveeHygrometerMonitor | temperatureWarningAlarm | | false | Enables warning alarms to be broadcast when temperature is out of specified range |
| goveeHygrometerMonitor | temperatureWarningMin | | 0 | The lower safe temperature<sup>2</sup> threshold <sup>3</sup> |
| goveeHygrometerMonitor | temperatureWarningMax | | 0 | The upper safe temperature<sup>2</sup> threshold <sup>3</sup> |
| goveeHygrometerMonitor | humidityWarningAlarm | | false | Enables warning alarms to be broadcast when humidity is out of specified range |
| goveeHygrometerMonitor | humidityWarningMin | | 0 | The lower safe humidity threshold <sup>3</sup> |
| goveeHygrometerMonitor | humidityWarningMax | | 0 | The upper safe humidity threshold <sup>3</sup> |
1. Available to both `goveeHygrometer` and `goveeHygrometerMonitor` thing types.
2. In °C
3. Only applies if alarm feature is enabled
## Channels
Govee things have the following channels in addition to the default bluetooth channels:
| Thing | Channel ID | Item Type | Description |
|-----------------------------|------------------|------------------------|----------------------------------------------------------------|
| goveeHygrometer<sup>1</sup> | temperature | Number:Temperature | The measured temperature |
| goveeHygrometer<sup>1</sup> | humidity | Number:Dimensionless | The measured relative humidity |
| goveeHygrometer<sup>1</sup> | battery | Number:Dimensionless | The measured battery percentage |
| goveeHygrometerMonitor | temperatureAlarm | Switch | Indicates if current temperature is out of range. <sup>2</sup> |
| goveeHygrometerMonitor | humidityAlarm | Switch | Indicates if current humidity is out of range. <sup>2</sup> |
1. Available to both `goveeHygrometer` and `goveeHygrometerMonitor` thing types.
2. Only applies if warning alarms are enabled in the configuration.
## Example
demo.things:
```
bluetooth:goveeHygrometer:hci0:beacon "Govee Temperature Humidity Monitor" (bluetooth:bluez:hci0) [ address="12:34:56:78:9A:BC" ]
```
demo.items:
```
Number:Temperature temperature "Room Temperature [%.1f %unit%]" { channel="bluetooth:goveeHygrometer:hci0:beacon:temperature" }
Number:Dimensionless humidity "Humidity [%.0f %unit%]" { channel="bluetooth:goveeHygrometer:hci0:beacon:humidity" }
Number:Dimensionless battery "Battery [%.0f %unit%]" { channel="bluetooth:goveeHygrometer:hci0:beacon:battery" }
```

View File

@ -0,0 +1,34 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns="http://maven.apache.org/POM/4.0.0"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.addons.reactor.bundles</artifactId>
<version>3.1.0-SNAPSHOT</version>
</parent>
<artifactId>org.openhab.binding.bluetooth.govee</artifactId>
<name>openHAB Add-ons :: Bundles :: Govee Bluetooth Adapter</name>
<dependencies>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.bluetooth</artifactId>
<version>${project.version}</version>
<scope>provided</scope>
</dependency>
<dependency>
<groupId>org.openhab.addons.bundles</groupId>
<artifactId>org.openhab.binding.bluetooth</artifactId>
<version>${project.version}</version>
<type>test-jar</type>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="UTF-8"?>
<features name="org.openhab.binding.bluetooth.govee-${project.version}" xmlns="http://karaf.apache.org/xmlns/features/v1.4.0">
<repository>mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features</repository>
<feature name="openhab-binding-bluetooth-govee" description="Bluetooth Binding Govee" version="${project.version}">
<feature>openhab-runtime-base</feature>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.govee/${project.version}</bundle>
</feature>
</features>

View File

@ -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();
}

View File

@ -0,0 +1,117 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.gattserial;
import java.util.Deque;
import java.util.Iterator;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ConcurrentLinkedDeque;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* @author Connor Petty - Initial Contribution
*
*/
@NonNullByDefault
public abstract class GattSocket<T extends GattMessage, R extends GattMessage> {
private static final Future<?> COMPLETED_FUTURE = CompletableFuture.completedFuture(null);
private final Deque<MessageProcessor> messageProcessors = new ConcurrentLinkedDeque<>();
public void registerMessageHandler(MessageHandler<T, R> messageHandler) {
// we need to use a dummy future since ConcurrentHashMap doesn't allow null values
messageProcessors.addFirst(new MessageProcessor(messageHandler, COMPLETED_FUTURE));
}
protected abstract ScheduledExecutorService getScheduler();
public void sendMessage(MessageServicer<T, R> messageServicer) {
T message = messageServicer.createMessage();
CompletableFuture<@Nullable Void> messageFuture = sendMessage(message);
Future<?> timeoutFuture = getScheduler().schedule(() -> {
messageFuture.completeExceptionally(new TimeoutException("Timeout while waiting for response"));
}, messageServicer.getTimeout(TimeUnit.NANOSECONDS), TimeUnit.NANOSECONDS);
MessageProcessor processor = new MessageProcessor(messageServicer, timeoutFuture);
messageProcessors.addLast(processor);
messageFuture.whenComplete((v, ex) -> {
if (ex instanceof CompletionException) {
ex = ex.getCause();
}
if (ex != null) {
if (messageServicer.handleFailedMessage(message, ex)) {
timeoutFuture.cancel(false);
messageProcessors.remove(processor);
}
}
});
}
public CompletableFuture<@Nullable Void> sendMessage(T message) {
List<byte[]> packets = createPackets(message);
var futures = packets.stream()//
.map(this::sendPacket)//
.toArray(CompletableFuture[]::new);
return CompletableFuture.allOf(futures);
}
protected List<byte[]> createPackets(T message) {
return List.of(message.getPayload());
}
protected abstract void parsePacket(byte[] packet, Consumer<R> messageHandler);
protected abstract CompletableFuture<@Nullable Void> sendPacket(byte[] value);
public void receivePacket(byte[] packet) {
parsePacket(packet, this::handleMessage);
}
private void handleMessage(R message) {
for (Iterator<MessageProcessor> it = messageProcessors.iterator(); it.hasNext();) {
MessageProcessor processor = it.next();
if (processor.messageHandler.handleReceivedMessage(message)) {
processor.timeoutFuture.cancel(false);
it.remove();
// we want to return after the first message servicer handles the message
if (processor.timeoutFuture != COMPLETED_FUTURE) {
return;
}
}
}
}
private class MessageProcessor {
private MessageHandler<T, R> messageHandler;
private Future<?> timeoutFuture;
public MessageProcessor(MessageHandler<T, R> messageHandler, Future<?> timeoutFuture) {
this.messageHandler = messageHandler;
this.timeoutFuture = timeoutFuture;
}
}
}

View File

@ -0,0 +1,37 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.gattserial;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Connor Petty - Initial Contribution
*
*/
@NonNullByDefault
public interface MessageHandler<T extends GattMessage, R extends GattMessage> {
/**
*
* @param payload
* @return true if this handler should be removed from the handler list
*/
public boolean handleReceivedMessage(R message);
/**
*
* @param payload
* @return true if this handler should be removed from the handler list
*/
public boolean handleFailedMessage(T message, Throwable th);
}

View File

@ -0,0 +1,28 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.gattserial;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Connor Petty - Initial Contribution
*
*/
@NonNullByDefault
public interface MessageServicer<T extends GattMessage, R extends GattMessage>
extends MessageHandler<T, R>, MessageSupplier<T> {
public long getTimeout(TimeUnit unit);
}

View File

@ -0,0 +1,22 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.gattserial;
/**
* @author Connor Petty - Initial Contribution
*
*/
public interface MessageSupplier<M extends GattMessage> {
public M createMessage();
}

View File

@ -0,0 +1,24 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.gattserial;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Connor Petty - Initial Contribution
*
*/
@NonNullByDefault
public abstract class SimpleGattSocket<M extends GattMessage> extends GattSocket<M, M> {
}

View File

@ -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;
}
}

View File

@ -0,0 +1,24 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.gattserial;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Connor Petty - Initial Contribution
*
*/
@NonNullByDefault
public interface SimpleMessageHandler<M extends GattMessage> extends MessageHandler<M, M> {
}

View File

@ -0,0 +1,24 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.gattserial;
import org.eclipse.jdt.annotation.NonNullByDefault;
/**
* @author Connor Petty - Initial Contribution
*
*/
@NonNullByDefault
public interface SimpleMessageServicer<M extends GattMessage> extends MessageServicer<M, M> {
}

View File

@ -0,0 +1,472 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.govee.internal;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.ScheduledThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.locks.Condition;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.bluetooth.BeaconBluetoothHandler;
import org.openhab.binding.bluetooth.BluetoothCharacteristic;
import org.openhab.binding.bluetooth.BluetoothCompletionStatus;
import org.openhab.binding.bluetooth.BluetoothDescriptor;
import org.openhab.binding.bluetooth.BluetoothDevice.ConnectionState;
import org.openhab.binding.bluetooth.BluetoothService;
import org.openhab.binding.bluetooth.notification.BluetoothConnectionStatusNotification;
import org.openhab.core.common.NamedThreadFactory;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.util.HexUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* This is a base implementation for more specific thing handlers that require constant connection to bluetooth devices.
*
* @author Kai Kreuzer - Initial contribution and API
* @deprecated once CompletableFutures are supported in the actual ConnectedBluetoothHandler, this class can be deleted
*/
@Deprecated
@NonNullByDefault
public class ConnectedBluetoothHandler extends BeaconBluetoothHandler {
private final Logger logger = LoggerFactory.getLogger(ConnectedBluetoothHandler.class);
private final Condition connectionCondition = deviceLock.newCondition();
private final Condition serviceDiscoveryCondition = deviceLock.newCondition();
private final Condition charCompleteCondition = deviceLock.newCondition();
private @Nullable Future<?> reconnectJob;
private @Nullable Future<?> pendingDisconnect;
private @Nullable BluetoothCharacteristic ongoingCharacteristic;
private @Nullable BluetoothCompletionStatus completeStatus;
private boolean connectOnDemand;
private int idleDisconnectDelayMs = 1000;
protected @Nullable ScheduledExecutorService connectionTaskExecutor;
private volatile boolean servicesDiscovered;
public ConnectedBluetoothHandler(Thing thing) {
super(thing);
}
@Override
public void initialize() {
// super.initialize adds callbacks that might require the connectionTaskExecutor to be present, so we initialize
// the connectionTaskExecutor first
ScheduledThreadPoolExecutor executor = new ScheduledThreadPoolExecutor(1,
new NamedThreadFactory("bluetooth-connection-" + thing.getThingTypeUID(), true));
executor.setExecuteExistingDelayedTasksAfterShutdownPolicy(false);
executor.setRemoveOnCancelPolicy(true);
connectionTaskExecutor = executor;
super.initialize();
connectOnDemand = true;
Object idleDisconnectDelayRaw = getConfig().get("idleDisconnectDelay");
idleDisconnectDelayMs = 1000;
if (idleDisconnectDelayRaw instanceof Number) {
idleDisconnectDelayMs = ((Number) idleDisconnectDelayRaw).intValue();
}
if (!connectOnDemand) {
reconnectJob = executor.scheduleWithFixedDelay(() -> {
try {
if (device.getConnectionState() != ConnectionState.CONNECTED) {
device.connect();
// we do not set the Thing status here, because we will anyhow receive a call to
// onConnectionStateChange
} else {
// just in case it was already connected to begin with
updateStatus(ThingStatus.ONLINE);
if (!servicesDiscovered && !device.discoverServices()) {
logger.debug("Error while discovering services");
}
}
} catch (RuntimeException ex) {
logger.warn("Unexpected error occurred", ex);
}
}, 0, 30, TimeUnit.SECONDS);
}
}
@Override
public void dispose() {
cancel(reconnectJob);
reconnectJob = null;
cancel(pendingDisconnect);
pendingDisconnect = null;
super.dispose();
shutdown(connectionTaskExecutor);
connectionTaskExecutor = null;
}
private static void cancel(@Nullable Future<?> future) {
if (future != null) {
future.cancel(true);
}
}
private void shutdown(@Nullable ScheduledExecutorService executor) {
if (executor != null) {
executor.shutdownNow();
}
}
private ScheduledExecutorService getConnectionTaskExecutor() {
var executor = connectionTaskExecutor;
if (executor == null) {
throw new IllegalStateException("characteristicScheduler has not been initialized");
}
return executor;
}
private void scheduleDisconnect() {
cancel(pendingDisconnect);
pendingDisconnect = getConnectionTaskExecutor().schedule(device::disconnect, idleDisconnectDelayMs,
TimeUnit.MILLISECONDS);
}
private void connectAndWait() throws ConnectionException, TimeoutException, InterruptedException {
if (device.getConnectionState() == ConnectionState.CONNECTED) {
return;
}
if (device.getConnectionState() != ConnectionState.CONNECTING) {
if (!device.connect()) {
throw new ConnectionException("Failed to start connecting");
}
}
logger.debug("waiting for connection");
if (!awaitConnection(1, TimeUnit.SECONDS)) {
throw new TimeoutException("Connection attempt timeout.");
}
logger.debug("connection successful");
if (!servicesDiscovered) {
logger.debug("discovering services");
device.discoverServices();
if (!awaitServiceDiscovery(20, TimeUnit.SECONDS)) {
throw new TimeoutException("Service discovery timeout");
}
logger.debug("service discovery successful");
}
}
private boolean awaitConnection(long timeout, TimeUnit unit) throws InterruptedException {
deviceLock.lock();
try {
long nanosTimeout = unit.toNanos(timeout);
while (device.getConnectionState() != ConnectionState.CONNECTED) {
if (nanosTimeout <= 0L) {
return false;
}
nanosTimeout = connectionCondition.awaitNanos(nanosTimeout);
}
} finally {
deviceLock.unlock();
}
return true;
}
private boolean awaitCharacteristicComplete(long timeout, TimeUnit unit) throws InterruptedException {
deviceLock.lock();
try {
long nanosTimeout = unit.toNanos(timeout);
while (ongoingCharacteristic != null) {
if (nanosTimeout <= 0L) {
return false;
}
nanosTimeout = charCompleteCondition.awaitNanos(nanosTimeout);
}
} finally {
deviceLock.unlock();
}
return true;
}
private boolean awaitServiceDiscovery(long timeout, TimeUnit unit) throws InterruptedException {
deviceLock.lock();
try {
long nanosTimeout = unit.toNanos(timeout);
while (!servicesDiscovered) {
if (nanosTimeout <= 0L) {
return false;
}
nanosTimeout = serviceDiscoveryCondition.awaitNanos(nanosTimeout);
}
} finally {
deviceLock.unlock();
}
return true;
}
private BluetoothCharacteristic connectAndGetCharacteristic(UUID serviceUUID, UUID characteristicUUID)
throws BluetoothException, TimeoutException, InterruptedException {
connectAndWait();
BluetoothService service = device.getServices(serviceUUID);
if (service == null) {
throw new BluetoothException("Service with uuid " + serviceUUID + " could not be found");
}
BluetoothCharacteristic characteristic = service.getCharacteristic(characteristicUUID);
if (characteristic == null) {
throw new BluetoothException("Characteristic with uuid " + characteristicUUID + " could not be found");
}
return characteristic;
}
private <T> CompletableFuture<T> executeWithConnection(UUID serviceUUID, UUID characteristicUUID,
CallableFunction<BluetoothCharacteristic, T> callable) {
CompletableFuture<T> future = new CompletableFuture<>();
var executor = connectionTaskExecutor;
if (executor != null) {
executor.execute(() -> {
cancel(pendingDisconnect);
try {
BluetoothCharacteristic characteristic = connectAndGetCharacteristic(serviceUUID,
characteristicUUID);
future.complete(callable.call(characteristic));
} catch (InterruptedException e) {
future.completeExceptionally(e);
return;// we don't want to schedule anything if we receive an interrupt
} catch (TimeoutException e) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, e.getMessage());
future.completeExceptionally(e);
} catch (Exception e) {
future.completeExceptionally(e);
}
if (connectOnDemand) {
scheduleDisconnect();
}
});
} else {
future.completeExceptionally(new IllegalStateException("characteristicScheduler has not been initialized"));
}
return future;
}
public CompletableFuture<@Nullable Void> enableNotifications(UUID serviceUUID, UUID characteristicUUID) {
return executeWithConnection(serviceUUID, characteristicUUID, characteristic -> {
if (!device.enableNotifications(characteristic)) {
throw new BluetoothException(
"Failed to start notifications for characteristic: " + characteristic.getUuid());
}
return null;
});
}
public CompletableFuture<@Nullable Void> writeCharacteristic(UUID serviceUUID, UUID characteristicUUID, byte[] data,
boolean enableNotification) {
return executeWithConnection(serviceUUID, characteristicUUID, characteristic -> {
if (enableNotification) {
if (!device.enableNotifications(characteristic)) {
throw new BluetoothException(
"Failed to start characteristic notification" + characteristic.getUuid());
}
}
// now block for completion
characteristic.setValue(data);
ongoingCharacteristic = characteristic;
if (!device.writeCharacteristic(characteristic)) {
throw new BluetoothException("Failed to start writing characteristic " + characteristic.getUuid());
}
if (!awaitCharacteristicComplete(1, TimeUnit.SECONDS)) {
ongoingCharacteristic = null;
throw new TimeoutException(
"Timeout waiting for characteristic " + characteristic.getUuid() + " write to finish");
}
if (completeStatus == BluetoothCompletionStatus.ERROR) {
throw new BluetoothException("Failed to write characteristic " + characteristic.getUuid());
}
logger.debug("Wrote {} to characteristic {} of device {}", HexUtils.bytesToHex(data),
characteristic.getUuid(), address);
return null;
});
}
public CompletableFuture<byte[]> readCharacteristic(UUID serviceUUID, UUID characteristicUUID) {
return executeWithConnection(serviceUUID, characteristicUUID, characteristic -> {
// now block for completion
ongoingCharacteristic = characteristic;
if (!device.readCharacteristic(characteristic)) {
throw new BluetoothException("Failed to start reading characteristic " + characteristic.getUuid());
}
if (!awaitCharacteristicComplete(1, TimeUnit.SECONDS)) {
ongoingCharacteristic = null;
throw new TimeoutException(
"Timeout waiting for characteristic " + characteristic.getUuid() + " read to finish");
}
if (completeStatus == BluetoothCompletionStatus.ERROR) {
throw new BluetoothException("Failed to read characteristic " + characteristic.getUuid());
}
byte[] data = characteristic.getByteValue();
logger.debug("Characteristic {} from {} has been read - value {}", characteristic.getUuid(), address,
HexUtils.bytesToHex(data));
return data;
});
}
@Override
protected void updateStatusBasedOnRssi(boolean receivedSignal) {
// if there is no signal, we can be sure we are OFFLINE, but if there is a signal, we also have to check whether
// we are connected.
if (receivedSignal) {
if (device.getConnectionState() == ConnectionState.CONNECTED) {
updateStatus(ThingStatus.ONLINE);
} else {
if (!connectOnDemand) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.NONE, "Device is not connected.");
}
}
} else {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
}
}
@Override
public void onConnectionStateChange(BluetoothConnectionStatusNotification connectionNotification) {
super.onConnectionStateChange(connectionNotification);
switch (connectionNotification.getConnectionState()) {
case DISCOVERED:
// The device is now known on the Bluetooth network, so we can do something...
if (!connectOnDemand) {
getConnectionTaskExecutor().submit(() -> {
if (device.getConnectionState() != ConnectionState.CONNECTED) {
if (!device.connect()) {
logger.debug("Error connecting to device after discovery.");
}
}
});
}
break;
case CONNECTED:
deviceLock.lock();
try {
connectionCondition.signal();
} finally {
deviceLock.unlock();
}
if (!connectOnDemand) {
getConnectionTaskExecutor().submit(() -> {
if (!servicesDiscovered && !device.discoverServices()) {
logger.debug("Error while discovering services");
}
});
}
break;
case DISCONNECTED:
var future = pendingDisconnect;
if (future != null) {
future.cancel(false);
}
if (!connectOnDemand) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR);
}
break;
default:
break;
}
}
@Override
public void onCharacteristicReadComplete(BluetoothCharacteristic characteristic, BluetoothCompletionStatus status) {
super.onCharacteristicReadComplete(characteristic, status);
deviceLock.lock();
try {
if (ongoingCharacteristic != null && ongoingCharacteristic.getUuid().equals(characteristic.getUuid())) {
completeStatus = status;
ongoingCharacteristic = null;
charCompleteCondition.signal();
}
} finally {
deviceLock.unlock();
}
}
@Override
public void onCharacteristicWriteComplete(BluetoothCharacteristic characteristic,
BluetoothCompletionStatus status) {
super.onCharacteristicWriteComplete(characteristic, status);
deviceLock.lock();
try {
if (ongoingCharacteristic != null && ongoingCharacteristic.getUuid().equals(characteristic.getUuid())) {
completeStatus = status;
ongoingCharacteristic = null;
charCompleteCondition.signal();
}
} finally {
deviceLock.unlock();
}
}
@Override
public void onServicesDiscovered() {
super.onServicesDiscovered();
deviceLock.lock();
try {
this.servicesDiscovered = true;
serviceDiscoveryCondition.signal();
} finally {
deviceLock.unlock();
}
logger.debug("Service discovery completed for '{}'", address);
}
@Override
public void onCharacteristicUpdate(BluetoothCharacteristic characteristic) {
super.onCharacteristicUpdate(characteristic);
if (logger.isDebugEnabled()) {
logger.debug("Recieved update {} to characteristic {} of device {}",
HexUtils.bytesToHex(characteristic.getByteValue()), characteristic.getUuid(), address);
}
}
@Override
public void onDescriptorUpdate(BluetoothDescriptor descriptor) {
super.onDescriptorUpdate(descriptor);
if (logger.isDebugEnabled()) {
logger.debug("Received update {} to descriptor {} of device {}", HexUtils.bytesToHex(descriptor.getValue()),
descriptor.getUuid(), address);
}
}
public static class BluetoothException extends Exception {
public BluetoothException(String message) {
super(message);
}
}
public static class ConnectionException extends BluetoothException {
public ConnectionException(String message) {
super(message);
}
}
@FunctionalInterface
public static interface CallableFunction<U, R> {
public R call(U arg) throws Exception;
}
}

View File

@ -0,0 +1,45 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.govee.internal;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.binding.bluetooth.BluetoothBindingConstants;
import org.openhab.core.thing.ThingTypeUID;
/**
* The {@link GoveeBindingConstants} class defines common constants, which are
* used across the whole binding.
*
* @author Connor Petty - Initial contribution
*/
@NonNullByDefault
public class GoveeBindingConstants {
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_HYGROMETER = new ThingTypeUID(BluetoothBindingConstants.BINDING_ID,
"goveeHygrometer");
public static final ThingTypeUID THING_TYPE_HYGROMETER_MONITOR = new ThingTypeUID(
BluetoothBindingConstants.BINDING_ID, "goveeHygrometerMonitor");
public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_HYGROMETER,
THING_TYPE_HYGROMETER_MONITOR);
// List of all Channel ids
public static final String CHANNEL_ID_BATTERY = "battery";
public static final String CHANNEL_ID_TEMPERATURE = "temperature";
public static final String CHANNEL_ID_TEMPERATURE_ALARM = "temperatureAlarm";
public static final String CHANNEL_ID_HUMIDITY = "humidity";
public static final String CHANNEL_ID_HUMIDITY_ALARM = "humidityAlarm";
}

View File

@ -0,0 +1,82 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.govee.internal;
import static org.openhab.binding.bluetooth.govee.internal.GoveeBindingConstants.SUPPORTED_THING_TYPES_UIDS;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.bluetooth.BluetoothBindingConstants;
import org.openhab.binding.bluetooth.discovery.BluetoothDiscoveryDevice;
import org.openhab.binding.bluetooth.discovery.BluetoothDiscoveryParticipant;
import org.openhab.core.config.discovery.DiscoveryResult;
import org.openhab.core.config.discovery.DiscoveryResultBuilder;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingTypeUID;
import org.openhab.core.thing.ThingUID;
import org.osgi.service.component.annotations.Component;
/**
* The {@link GoveeDiscoveryParticipant} handles discovery of Govee bluetooth devices
*
* @author Connor Petty - Initial contribution
*/
@NonNullByDefault
@Component(service = BluetoothDiscoveryParticipant.class)
public class GoveeDiscoveryParticipant implements BluetoothDiscoveryParticipant {
@Override
public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
return SUPPORTED_THING_TYPES_UIDS;
}
private ThingUID getThingUID(BluetoothDiscoveryDevice device, ThingTypeUID thingTypeUID) {
return new ThingUID(thingTypeUID, device.getAdapter().getUID(),
device.getAddress().toString().toLowerCase().replace(":", ""));
}
@Override
public @Nullable ThingUID getThingUID(BluetoothDiscoveryDevice device) {
GoveeModel model = GoveeModel.getGoveeModel(device);
if (model != null) {
return getThingUID(device, model.getThingTypeUID());
}
return null;
}
@Override
public @Nullable DiscoveryResult createResult(BluetoothDiscoveryDevice device) {
GoveeModel model = GoveeModel.getGoveeModel(device);
if (model != null) {
Map<String, Object> properties = new HashMap<>();
properties.put(BluetoothBindingConstants.CONFIGURATION_ADDRESS, device.getAddress().toString());
properties.put(Thing.PROPERTY_VENDOR, "Govee");
properties.put(Thing.PROPERTY_MODEL_ID, model.name());
Integer txPower = device.getTxPower();
if (txPower != null) {
properties.put(BluetoothBindingConstants.PROPERTY_TXPOWER, Integer.toString(txPower));
}
// Create the discovery result and add to the inbox
return DiscoveryResultBuilder.create(getThingUID(device, model.getThingTypeUID()))
.withProperties(properties)
.withRepresentationProperty(BluetoothBindingConstants.CONFIGURATION_ADDRESS)
.withBridge(device.getAdapter().getUID()).withLabel(model.getLabel()).build();
}
return null;
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,75 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.govee.internal;
import javax.measure.quantity.Dimensionless;
import javax.measure.quantity.Temperature;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.WarningSettingsDTO;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.library.unit.Units;
/**
* @author Connor Petty - Initial contribution
*
*/
@NonNullByDefault
public class GoveeHygrometerConfiguration {
public int refreshInterval = 300;
public @Nullable Double temperatureCalibration;
public boolean temperatureWarningAlarm = false;
public double temperatureWarningMin;
public double temperatureWarningMax;
public @Nullable Double humidityCalibration;
public boolean humidityWarningAlarm = false;
public double humidityWarningMin;
public double humidityWarningMax;
public @Nullable QuantityType<Temperature> getTemperatureCalibration() {
var temCali = temperatureCalibration;
if (temCali != null) {
return new QuantityType<>(temCali, SIUnits.CELSIUS);
}
return null;
}
public @Nullable QuantityType<Dimensionless> getHumidityCalibration() {
var humCali = humidityCalibration;
if (humCali != null) {
return new QuantityType<>(humCali, Units.PERCENT);
}
return null;
}
public WarningSettingsDTO<Temperature> getTemperatureWarningSettings() {
WarningSettingsDTO<Temperature> temWarnSettings = new WarningSettingsDTO<>();
temWarnSettings.enableAlarm = OnOffType.from(temperatureWarningAlarm);
temWarnSettings.min = new QuantityType<>(temperatureWarningMin, SIUnits.CELSIUS);
temWarnSettings.max = new QuantityType<>(temperatureWarningMax, SIUnits.CELSIUS);
return temWarnSettings;
}
public WarningSettingsDTO<Dimensionless> getHumidityWarningSettings() {
WarningSettingsDTO<Dimensionless> humWarnSettings = new WarningSettingsDTO<>();
humWarnSettings.enableAlarm = OnOffType.from(humidityWarningAlarm);
humWarnSettings.min = new QuantityType<>(humidityWarningMin, Units.PERCENT);
humWarnSettings.max = new QuantityType<>(humidityWarningMax, Units.PERCENT);
return humWarnSettings;
}
}

View File

@ -0,0 +1,424 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.govee.internal;
import static org.openhab.binding.bluetooth.govee.internal.GoveeBindingConstants.*;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Future;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import javax.measure.Quantity;
import javax.measure.quantity.Dimensionless;
import javax.measure.quantity.Temperature;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.bluetooth.BluetoothCharacteristic;
import org.openhab.binding.bluetooth.BluetoothDevice.ConnectionState;
import org.openhab.binding.bluetooth.gattserial.MessageServicer;
import org.openhab.binding.bluetooth.gattserial.SimpleGattSocket;
import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetBatteryCommand;
import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetOrSetHumCaliCommand;
import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetOrSetHumWarningCommand;
import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetOrSetTemCaliCommand;
import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetOrSetTemWarningCommand;
import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GetTemHumCommand;
import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.GoveeMessage;
import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.TemHumDTO;
import org.openhab.binding.bluetooth.govee.internal.command.hygrometer.WarningSettingsDTO;
import org.openhab.binding.bluetooth.notification.BluetoothScanNotification;
import org.openhab.binding.bluetooth.util.HeritableFuture;
import org.openhab.binding.bluetooth.util.RetryException;
import org.openhab.binding.bluetooth.util.RetryFuture;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.SIUnits;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* @author Connor Petty - Initial contribution
*
*/
@NonNullByDefault
public class GoveeHygrometerHandler extends ConnectedBluetoothHandler {
private static final UUID SERVICE_UUID = UUID.fromString("494e5445-4c4c-495f-524f-434b535f4857");
private static final UUID PROTOCOL_CHAR_UUID = UUID.fromString("494e5445-4c4c-495f-524f-434b535f2011");
private static final UUID KEEP_ALIVE_CHAR_UUID = UUID.fromString("494e5445-4c4c-495f-524f-434b535f2012");
private static final byte[] SCAN_HEADER = { (byte) 0xFF, (byte) 0x88, (byte) 0xEC };
private final Logger logger = LoggerFactory.getLogger(GoveeHygrometerHandler.class);
private final CommandSocket commandSocket = new CommandSocket();
private GoveeHygrometerConfiguration config = new GoveeHygrometerConfiguration();
private GoveeModel model = GoveeModel.H5074;// we use this as our default model
private CompletableFuture<?> initializeJob = CompletableFuture.completedFuture(null);// initially set to a dummy
// future
private Future<?> scanJob = CompletableFuture.completedFuture(null);
private Future<?> keepAliveJob = CompletableFuture.completedFuture(null);
public GoveeHygrometerHandler(Thing thing) {
super(thing);
}
@Override
public void initialize() {
super.initialize();
config = getConfigAs(GoveeHygrometerConfiguration.class);
Map<String, String> properties = thing.getProperties();
String modelProp = properties.get(Thing.PROPERTY_MODEL_ID);
model = GoveeModel.H5074;
if (modelProp != null) {
try {
model = GoveeModel.valueOf(modelProp);
} catch (IllegalArgumentException ex) {
// ignore
}
}
logger.debug("Initializing Govee Hygrometer {} model: {}", address, model);
initializeJob = RetryFuture.composeWithRetry(this::createInitSettingsJob, scheduler)//
.thenRun(() -> {
updateStatus(ThingStatus.ONLINE);
});
scanJob = scheduler.scheduleWithFixedDelay(() -> {
try {
if (initializeJob.isDone() && !initializeJob.isCompletedExceptionally()) {
logger.debug("refreshing temperature, humidity, and battery");
refreshBattery().join();
refreshTemperatureAndHumidity().join();
connectionTaskExecutor.execute(device::disconnect);
updateStatus(ThingStatus.ONLINE);
}
} catch (RuntimeException ex) {
logger.warn("unable to refresh", ex);
}
}, 0, config.refreshInterval, TimeUnit.SECONDS);
keepAliveJob = connectionTaskExecutor.scheduleWithFixedDelay(() -> {
if (device.getConnectionState() == ConnectionState.CONNECTED) {
try {
GoveeMessage message = new GoveeMessage((byte) 0xAA, (byte) 1, null);
writeCharacteristic(SERVICE_UUID, KEEP_ALIVE_CHAR_UUID, message.getPayload(), false);
} catch (RuntimeException ex) {
logger.warn("unable to send keep alive", ex);
}
}
}, 1, 2, TimeUnit.SECONDS);
}
@Override
public void dispose() {
initializeJob.cancel(false);
scanJob.cancel(false);
keepAliveJob.cancel(false);
super.dispose();
}
private CompletableFuture<@Nullable ?> createInitSettingsJob() {
logger.debug("Initializing Govee Hygrometer {} settings", address);
QuantityType<Temperature> temCali = config.getTemperatureCalibration();
QuantityType<Dimensionless> humCali = config.getHumidityCalibration();
WarningSettingsDTO<Temperature> temWarnSettings = config.getTemperatureWarningSettings();
WarningSettingsDTO<Dimensionless> humWarnSettings = config.getHumidityWarningSettings();
final CompletableFuture<@Nullable ?> parent = new HeritableFuture<>();
CompletableFuture<@Nullable ?> future = parent;
future.complete(null);
if (temCali != null) {
future = future.thenCompose(v -> {
CompletableFuture<@Nullable QuantityType<Temperature>> caliFuture = parent.newIncompleteFuture();
commandSocket.sendMessage(new GetOrSetTemCaliCommand(temCali, caliFuture));
return caliFuture;
});
}
if (humCali != null) {
future = future.thenCompose(v -> {
CompletableFuture<@Nullable QuantityType<Dimensionless>> caliFuture = parent.newIncompleteFuture();
commandSocket.sendMessage(new GetOrSetHumCaliCommand(humCali, caliFuture));
return caliFuture;
});
}
if (model.supportsWarningBroadcast()) {
future = future.thenCompose(v -> {
CompletableFuture<@Nullable WarningSettingsDTO<Temperature>> temWarnFuture = parent
.newIncompleteFuture();
commandSocket.sendMessage(new GetOrSetTemWarningCommand(temWarnSettings, temWarnFuture));
return temWarnFuture;
}).thenCompose(v -> {
CompletableFuture<@Nullable WarningSettingsDTO<Dimensionless>> humWarnFuture = parent
.newIncompleteFuture();
commandSocket.sendMessage(new GetOrSetHumWarningCommand(humWarnSettings, humWarnFuture));
return humWarnFuture;
});
}
// CompletableFuture.exceptionallyCompose isn't available yet so we have to compose it manually for now.
CompletableFuture<@Nullable Void> retFuture = future.newIncompleteFuture();
future.whenComplete((v, th) -> {
if (th instanceof CompletionException) {
th = th.getCause();
}
if (th instanceof RuntimeException) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
"Failed to initialize device: " + th.getMessage());
retFuture.completeExceptionally(th);
} else if (th != null) {
logger.debug("Failure to initialize device: {}. Retrying in 30 seconds", th.getMessage());
retFuture.completeExceptionally(new RetryException(30, TimeUnit.SECONDS));
} else {
retFuture.complete(null);
}
});
return retFuture;
}
@Override
public void handleCommand(ChannelUID channelUID, Command command) {
super.handleCommand(channelUID, command);
switch (channelUID.getId()) {
case CHANNEL_ID_BATTERY:
if (command == RefreshType.REFRESH) {
refreshBattery();
}
return;
case CHANNEL_ID_TEMPERATURE:
case CHANNEL_ID_HUMIDITY:
if (command == RefreshType.REFRESH) {
refreshTemperatureAndHumidity();
}
return;
}
}
private CompletableFuture<@Nullable ?> refreshBattery() {
CompletableFuture<@Nullable QuantityType<Dimensionless>> future = new CompletableFuture<>();
commandSocket.sendMessage(new GetBatteryCommand(future));
future.whenCompleteAsync(this::updateBattery, scheduler);
return future;
}
private void updateBattery(@Nullable QuantityType<Dimensionless> result, @Nullable Throwable th) {
if (th != null) {
logger.debug("Failed to get battery: {}", th.getMessage());
}
if (result == null) {
return;
}
updateState(CHANNEL_ID_BATTERY, result);
}
private CompletableFuture<@Nullable ?> refreshTemperatureAndHumidity() {
CompletableFuture<@Nullable TemHumDTO> future = new CompletableFuture<>();
commandSocket.sendMessage(new GetTemHumCommand(future));
future.whenCompleteAsync(this::updateTemperatureAndHumidity, scheduler);
return future;
}
private void updateTemperatureAndHumidity(@Nullable TemHumDTO result, @Nullable Throwable th) {
if (th != null) {
logger.debug("Failed to get temperature/humidity: {}", th.getMessage());
}
if (result == null) {
return;
}
QuantityType<Temperature> tem = result.temperature;
QuantityType<Dimensionless> hum = result.humidity;
if (tem == null || hum == null) {
return;
}
updateState(CHANNEL_ID_TEMPERATURE, tem);
updateState(CHANNEL_ID_HUMIDITY, hum);
if (model.supportsWarningBroadcast()) {
updateAlarm(CHANNEL_ID_TEMPERATURE_ALARM, tem, config.getTemperatureWarningSettings());
updateAlarm(CHANNEL_ID_HUMIDITY_ALARM, hum, config.getHumidityWarningSettings());
}
}
private <T extends Quantity<T>> void updateAlarm(String channelName, QuantityType<T> quantity,
WarningSettingsDTO<T> settings) {
boolean outOfRange = quantity.compareTo(settings.min) < 0 || settings.max.compareTo(quantity) < 0;
updateState(channelName, OnOffType.from(outOfRange));
}
private int scanPacketSize() {
switch (model) {
case B5175:
case B5178:
return 10;
case H5179:
return 8;
default:
return 7;
}
}
@Override
public void onScanRecordReceived(BluetoothScanNotification scanNotification) {
super.onScanRecordReceived(scanNotification);
byte[] scanData = scanNotification.getData();
int dataPacketSize = scanPacketSize();
int recordIndex = indexOfTemHumRecord(scanData);
if (recordIndex == -1 || recordIndex + dataPacketSize >= scanData.length) {
return;
}
ByteBuffer data = ByteBuffer.wrap(scanData, recordIndex, dataPacketSize);
short temperature;
int humidity;
int battery;
int wifiLevel = 0;
switch (model) {
default:
data.position(2);// we throw this away
// fall through
case H5072:
case H5075:
data.order(ByteOrder.BIG_ENDIAN);
int l = data.getInt();
l = l & 0xFFFFFF;
boolean positive = (l & 0x800000) == 0;
int tem = (short) ((l / 1000) * 10);
if (!positive) {
tem = -tem;
}
temperature = (short) tem;
humidity = (l % 1000) * 10;
battery = data.get();
break;
case H5179:
data.order(ByteOrder.LITTLE_ENDIAN);
data.position(3);
temperature = data.getShort();
humidity = data.getShort();
battery = Byte.toUnsignedInt(data.get());
break;
case H5051:
case H5052:
case H5071:
case H5074:
data.order(ByteOrder.LITTLE_ENDIAN);
boolean hasWifi = data.get() == 0;
temperature = data.getShort();
humidity = Short.toUnsignedInt(data.getShort());
battery = Byte.toUnsignedInt(data.get());
wifiLevel = hasWifi ? Byte.toUnsignedInt(data.get()) : 0;
break;
}
updateTemHumBattery(temperature, humidity, battery, wifiLevel);
}
private static int indexOfTemHumRecord(byte @Nullable [] scanData) {
if (scanData == null || scanData.length != 62) {
return -1;
}
int i = 0;
while (i < 57) {
int recordLength = scanData[i] & 0xFF;
if (scanData[i + 1] == SCAN_HEADER[0]//
&& scanData[i + 2] == SCAN_HEADER[1]//
&& scanData[i + 3] == SCAN_HEADER[2]) {
return i + 4;
}
i += recordLength + 1;
}
return -1;
}
private void updateTemHumBattery(short tem, int hum, int battery, int wifiLevel) {
if (Short.toUnsignedInt(tem) == 0xFFFF || hum == 0xFFFF) {
logger.trace("Govee device [{}] received invalid data", this.address);
return;
}
logger.debug("Govee device [{}] received broadcast: tem = {}, hum = {}, battery = {}, wifiLevel = {}",
this.address, tem, hum, battery, wifiLevel);
if (tem == 0 && hum == 0 && battery == 0) {
logger.trace("Govee device [{}] values are zero", this.address);
return;
}
if (tem < -4000 || tem > 10000) {
logger.trace("Govee device [{}] invalid temperature value: {}", this.address, tem);
return;
}
if (hum > 10000) {
logger.trace("Govee device [{}] invalid humidity valie: {}", this.address, hum);
return;
}
TemHumDTO temhum = new TemHumDTO();
temhum.temperature = new QuantityType<>(tem / 100.0, SIUnits.CELSIUS);
temhum.humidity = new QuantityType<>(hum / 100.0, Units.PERCENT);
updateTemperatureAndHumidity(temhum, null);
updateBattery(new QuantityType<>(battery, Units.PERCENT), null);
}
@Override
public void onCharacteristicUpdate(BluetoothCharacteristic characteristic) {
super.onCharacteristicUpdate(characteristic);
commandSocket.receivePacket(characteristic.getByteValue());
}
private class CommandSocket extends SimpleGattSocket<GoveeMessage> {
@Override
protected ScheduledExecutorService getScheduler() {
return scheduler;
}
@Override
public void sendMessage(MessageServicer<GoveeMessage, GoveeMessage> messageServicer) {
logger.debug("sending message: {}", messageServicer.getClass().getSimpleName());
super.sendMessage(messageServicer);
}
@Override
protected void parsePacket(byte[] packet, Consumer<GoveeMessage> messageHandler) {
messageHandler.accept(new GoveeMessage(packet));
}
@Override
protected CompletableFuture<@Nullable Void> sendPacket(byte[] data) {
return writeCharacteristic(SERVICE_UUID, PROTOCOL_CHAR_UUID, data, true);
}
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,54 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
import java.util.concurrent.CompletableFuture;
import javax.measure.quantity.Dimensionless;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.Units;
/**
* @author Connor Petty - Initial Contribution
*
*/
@NonNullByDefault
public class GetBatteryCommand extends GetCommand {
private CompletableFuture<@Nullable QuantityType<Dimensionless>> resultHandler;
public GetBatteryCommand(CompletableFuture<@Nullable QuantityType<Dimensionless>> resultHandler) {
this.resultHandler = resultHandler;
}
@Override
public byte getCommandCode() {
return 8;
}
@Override
public void handleResponse(byte @Nullable [] data, @Nullable Throwable th) {
if (th != null) {
resultHandler.completeExceptionally(th);
}
if (data != null) {
int value = data[0] & 0xFF;
resultHandler.complete(new QuantityType<Dimensionless>(value, Units.PERCENT));
} else {
resultHandler.complete(null);
}
}
}

View File

@ -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;
}
}

View File

@ -0,0 +1,86 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.concurrent.CompletableFuture;
import javax.measure.quantity.Dimensionless;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.Units;
/**
* @author Connor Petty - Initial Contribution
*
*/
@NonNullByDefault
public class GetOrSetHumCaliCommand extends GoveeCommand {
private final CompletableFuture<@Nullable QuantityType<Dimensionless>> resultHandler;
private final @Nullable QuantityType<Dimensionless> value;
public GetOrSetHumCaliCommand(CompletableFuture<@Nullable QuantityType<Dimensionless>> resultHandler) {
this.value = null;
this.resultHandler = resultHandler;
}
public GetOrSetHumCaliCommand(QuantityType<Dimensionless> value,
CompletableFuture<@Nullable QuantityType<Dimensionless>> resultHandler) {
this.value = value;
this.resultHandler = resultHandler;
}
@Override
public byte getCommandType() {
return value != null ? WRITE_TYPE : READ_TYPE;
}
@Override
public byte getCommandCode() {
return 6;
}
private static short convertQuantity(QuantityType<Dimensionless> quantity) {
var percentQuantity = quantity.toUnit(Units.PERCENT);
if (percentQuantity == null) {
throw new IllegalArgumentException("Unable to convert quantity to percent");
}
return (short) (percentQuantity.doubleValue() * 100);
}
@Override
protected byte @Nullable [] getData() {
var v = value;
if (v != null) {
return ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(convertQuantity(v)).array();
}
return null;
}
@Override
public void handleResponse(byte @Nullable [] data, @Nullable Throwable th) {
if (th != null) {
resultHandler.completeExceptionally(th);
}
if (data != null) {
short hum = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).getShort();
resultHandler.complete(new QuantityType<>(hum / 100.0, Units.PERCENT));
} else {
resultHandler.complete(null);
}
}
}

View File

@ -0,0 +1,97 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.concurrent.CompletableFuture;
import javax.measure.quantity.Dimensionless;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.Units;
/**
* @author Connor Petty - Initial Contribution
*
*/
@NonNullByDefault
public class GetOrSetHumWarningCommand extends GoveeCommand {
private final @Nullable WarningSettingsDTO<Dimensionless> settings;
private final CompletableFuture<@Nullable WarningSettingsDTO<Dimensionless>> resultHandler;
public GetOrSetHumWarningCommand(CompletableFuture<@Nullable WarningSettingsDTO<Dimensionless>> resultHandler) {
this.settings = null;
this.resultHandler = resultHandler;
}
public GetOrSetHumWarningCommand(WarningSettingsDTO<Dimensionless> settings,
CompletableFuture<@Nullable WarningSettingsDTO<Dimensionless>> resultHandler) {
this.settings = settings;
this.resultHandler = resultHandler;
}
@Override
public byte getCommandType() {
return settings == null ? READ_TYPE : WRITE_TYPE;
}
@Override
public byte getCommandCode() {
return 3;
}
private static short convertQuantity(QuantityType<Dimensionless> quantity) {
var percentQuantity = quantity.toUnit(Units.PERCENT);
if (percentQuantity == null) {
throw new IllegalArgumentException("Unable to convert quantity to percent");
}
return (short) (percentQuantity.doubleValue() * 100);
}
@Override
protected byte @Nullable [] getData() {
if (settings == null) {
return null;
}
ByteBuffer buffer = ByteBuffer.allocate(5).order(ByteOrder.LITTLE_ENDIAN);
buffer.put(settings.enableAlarm == OnOffType.ON ? (byte) 1 : 0);
buffer.putShort(convertQuantity(settings.min));
buffer.putShort(convertQuantity(settings.max));
return buffer.array();
}
@Override
public void handleResponse(byte @Nullable [] data, @Nullable Throwable th) {
if (th != null) {
resultHandler.completeExceptionally(th);
}
if (data != null) {
WarningSettingsDTO<Dimensionless> result = new WarningSettingsDTO<Dimensionless>();
ByteBuffer buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN);
result.enableAlarm = OnOffType.from(buffer.get() == 1);
result.min = new QuantityType<Dimensionless>(buffer.getShort() / 100.0, Units.PERCENT);
result.max = new QuantityType<Dimensionless>(buffer.getShort() / 100.0, Units.PERCENT);
resultHandler.complete(result);
} else {
resultHandler.complete(null);
}
}
}

View File

@ -0,0 +1,85 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.concurrent.CompletableFuture;
import javax.measure.quantity.Temperature;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.SIUnits;
/**
* @author Connor Petty - Initial Contribution
*
*/
@NonNullByDefault
public class GetOrSetTemCaliCommand extends GoveeCommand {
private final CompletableFuture<@Nullable QuantityType<Temperature>> resultHandler;
private final @Nullable QuantityType<Temperature> value;
public GetOrSetTemCaliCommand(CompletableFuture<@Nullable QuantityType<Temperature>> resultHandler) {
this.value = null;
this.resultHandler = resultHandler;
}
public GetOrSetTemCaliCommand(QuantityType<Temperature> value,
CompletableFuture<@Nullable QuantityType<Temperature>> resultHandler) {
this.value = value;
this.resultHandler = resultHandler;
}
@Override
public byte getCommandType() {
return value != null ? WRITE_TYPE : READ_TYPE;
}
@Override
public byte getCommandCode() {
return 7;
}
private static short convertQuantity(QuantityType<Temperature> quantity) {
var celciusQuantity = quantity.toUnit(SIUnits.CELSIUS);
if (celciusQuantity == null) {
throw new IllegalArgumentException("Unable to convert quantity to celcius");
}
return (short) (celciusQuantity.doubleValue() * 100);
}
@Override
protected byte @Nullable [] getData() {
var v = value;
if (v != null) {
return ByteBuffer.allocate(2).order(ByteOrder.LITTLE_ENDIAN).putShort(convertQuantity(v)).array();
}
return null;
}
@Override
public void handleResponse(byte @Nullable [] data, @Nullable Throwable th) {
if (th != null) {
resultHandler.completeExceptionally(th);
}
if (data != null) {
short tem = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN).getShort();
resultHandler.complete(new QuantityType<>(tem / 100.0, SIUnits.CELSIUS));
} else {
resultHandler.complete(null);
}
}
}

View File

@ -0,0 +1,96 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
import java.nio.ByteBuffer;
import java.nio.ByteOrder;
import java.util.concurrent.CompletableFuture;
import javax.measure.quantity.Temperature;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.unit.SIUnits;
/**
* @author Connor Petty - Initial Contribution
*
*/
@NonNullByDefault
public class GetOrSetTemWarningCommand extends GoveeCommand {
private final @Nullable WarningSettingsDTO<Temperature> settings;
private final CompletableFuture<@Nullable WarningSettingsDTO<Temperature>> resultHandler;
public GetOrSetTemWarningCommand(CompletableFuture<@Nullable WarningSettingsDTO<Temperature>> resultHandler) {
this.settings = null;
this.resultHandler = resultHandler;
}
public GetOrSetTemWarningCommand(WarningSettingsDTO<Temperature> settings,
CompletableFuture<@Nullable WarningSettingsDTO<Temperature>> resultHandler) {
this.settings = settings;
this.resultHandler = resultHandler;
}
@Override
public byte getCommandType() {
return settings == null ? READ_TYPE : WRITE_TYPE;
}
@Override
public byte getCommandCode() {
return 4;
}
private static short convertQuantity(QuantityType<Temperature> quantity) {
var celciusQuantity = quantity.toUnit(SIUnits.CELSIUS);
if (celciusQuantity == null) {
throw new IllegalArgumentException("Unable to convert quantity to celcius");
}
return (short) (celciusQuantity.doubleValue() * 100);
}
@Override
protected byte @Nullable [] getData() {
var settings = this.settings;
if (settings == null) {
return null;
}
ByteBuffer buffer = ByteBuffer.allocate(5).order(ByteOrder.LITTLE_ENDIAN);
buffer.put(settings.enableAlarm == OnOffType.ON ? (byte) 1 : 0);
buffer.putShort(convertQuantity(settings.min));
buffer.putShort(convertQuantity(settings.max));
return buffer.array();
}
@Override
public void handleResponse(byte @Nullable [] data, @Nullable Throwable th) {
if (th != null) {
resultHandler.completeExceptionally(th);
}
if (data != null) {
WarningSettingsDTO<Temperature> result = new WarningSettingsDTO<Temperature>();
ByteBuffer buffer = ByteBuffer.wrap(data).order(ByteOrder.LITTLE_ENDIAN);
result.enableAlarm = OnOffType.from(buffer.get() == 1);
result.min = new QuantityType<Temperature>(buffer.getShort() / 100.0, SIUnits.CELSIUS);
result.max = new QuantityType<Temperature>(buffer.getShort() / 100.0, SIUnits.CELSIUS);
resultHandler.complete(result);
} else {
resultHandler.complete(null);
}
}
}

View File

@ -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);
}
}
}

View File

@ -0,0 +1,70 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.bluetooth.gattserial.SimpleMessageServicer;
/**
* @author Connor Petty - Initial Contribution
*
*/
@NonNullByDefault
public abstract class GoveeCommand implements SimpleMessageServicer<GoveeMessage> {
public static final byte READ_TYPE = -86;
public static final byte WRITE_TYPE = 51;
public abstract byte getCommandType();
public abstract byte getCommandCode();
protected abstract byte @Nullable [] getData();
@Override
public long getTimeout(TimeUnit unit) {
return unit.convert(60, TimeUnit.SECONDS);
}
@Override
public GoveeMessage createMessage() {
return new GoveeMessage(getCommandType(), getCommandCode(), getData());
}
@Override
public boolean handleFailedMessage(GoveeMessage message, Throwable th) {
if (matches(message)) {
handleResponse(null, th);
return true;
}
return false;
}
@Override
public boolean handleReceivedMessage(GoveeMessage message) {
if (matches(message)) {
handleResponse(message.getData(), null);
return true;
}
return false;
}
public abstract void handleResponse(byte @Nullable [] data, @Nullable Throwable th);
protected boolean matches(GoveeMessage message) {
return message.getCommandType() == getCommandType() && message.getCommandCode() == getCommandCode();
}
}

View File

@ -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;
}
}

View File

@ -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;
}
}

View File

@ -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;
}

View File

@ -0,0 +1,28 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.govee.internal.command.hygrometer;
import javax.measure.Quantity;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
/**
* @author Connor Petty - Initial contribution
*
*/
public class WarningSettingsDTO<Q extends Quantity<Q>> {
public OnOffType enableAlarm;
public QuantityType<Q> min;
public QuantityType<Q> max;
}

View File

@ -0,0 +1,190 @@
<?xml version="1.0" encoding="UTF-8"?>
<thing:thing-descriptions bindingId="bluetooth"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
xsi:schemaLocation="https://openhab.org/schemas/thing-description/v1.0.0 https://openhab.org/schemas/thing-description-1.0.0.xsd">
<thing-type id="goveeHygrometer">
<supported-bridge-type-refs>
<bridge-type-ref id="roaming"/>
<bridge-type-ref id="bluegiga"/>
<bridge-type-ref id="bluez"/>
</supported-bridge-type-refs>
<label>Govee Hygrometer</label>
<description>Govee Thermo-Hygrometer</description>
<channels>
<channel id="rssi" typeId="rssi"/>
<channel id="battery" typeId="system.battery-level"/>
<channel id="temperature" typeId="govee-temperature"/>
<channel id="humidity" typeId="system.atmospheric-humidity"/>
</channels>
<representation-property>address</representation-property>
<config-description>
<parameter-group name="calibration">
<label>Calibration</label>
<description>Sensor calibration settings.</description>
<advanced>true</advanced>
</parameter-group>
<parameter name="address" type="text" required="true">
<label>Address</label>
<description>Bluetooth address in XX:XX:XX:XX:XX:XX format</description>
</parameter>
<parameter name="refreshInterval" type="integer" unit="s" required="true">
<label>Refresh Interval</label>
<description>The frequency at which battery, temperature, and humidity data will refresh</description>
<default>300</default>
<advanced>true</advanced>
</parameter>
<parameter name="temperatureCalibration" type="decimal" min="-1.6" max="1.6" groupName="calibration"
unit="Cel">
<label>Temperature Calibration</label>
<description>Adds offset to reported temperature</description>
<advanced>true</advanced>
</parameter>
<parameter name="humidityCalibration" type="decimal" min="-9" max="9" groupName="calibration" unit="%">
<label>Humidity Calibration</label>
<description>Adds offset to reported humidity</description>
<advanced>true</advanced>
</parameter>
</config-description>
</thing-type>
<thing-type id="goveeHygrometerMonitor">
<supported-bridge-type-refs>
<bridge-type-ref id="roaming"/>
<bridge-type-ref id="bluegiga"/>
<bridge-type-ref id="bluez"/>
</supported-bridge-type-refs>
<label>Govee Monitoring Hygrometer</label>
<description>Govee Thermo-Hygrometer w/ Warning Alarms</description>
<channels>
<channel id="rssi" typeId="rssi"/>
<channel id="battery" typeId="system.battery-level"/>
<channel id="temperature" typeId="govee-temperature"/>
<channel id="temperatureAlarm" typeId="govee-temperature-alarm"/>
<channel id="humidity" typeId="system.atmospheric-humidity"/>
<channel id="humidityAlarm" typeId="govee-humidity-alarm"/>
</channels>
<representation-property>address</representation-property>
<config-description>
<parameter-group name="calibration">
<label>Calibration</label>
<description>Sensor calibration settings.</description>
<advanced>true</advanced>
</parameter-group>
<parameter-group name="alarms">
<label>Alarm</label>
<description>Alarm settings.</description>
<advanced>true</advanced>
</parameter-group>
<parameter name="address" type="text" required="true">
<label>Address</label>
<description>Bluetooth address in XX:XX:XX:XX:XX:XX format</description>
</parameter>
<parameter name="refreshInterval" type="integer" unit="s" required="true">
<label>Refresh Interval</label>
<description>The frequency at which battery, temperature, and humidity data will refresh</description>
<default>300</default>
<advanced>true</advanced>
</parameter>
<parameter name="temperatureCalibration" type="decimal" min="-1.6" max="1.6" groupName="calibration"
unit="Cel">
<label>Temperature Calibration</label>
<description>Adds offset to reported temperature</description>
<advanced>true</advanced>
</parameter>
<parameter name="temperatureWarningAlarm" type="boolean" groupName="alarms" required="true">
<label>Broadcast Temperature Warning</label>
<description>If enabled, the Govee device will notify openHAB if temperature is out of the specified range</description>
<default>false</default>
<advanced>true</advanced>
</parameter>
<parameter name="temperatureWarningMin" type="decimal" min="-20" max="60" step="0.2" groupName="alarms"
unit="Cel" required="true">
<label>Min Warning Temperature</label>
<description>Sets the lowest acceptable temperature value before a warning should be issued</description>
<default>0</default>
<advanced>true</advanced>
</parameter>
<parameter name="temperatureWarningMax" type="decimal" min="-20" max="60" step="0.2" groupName="alarms"
unit="Cel" required="true">
<label>Max Warning Temperature</label>
<description>Sets the highest acceptable temperature value before a warning should be issued</description>
<default>0</default>
<advanced>true</advanced>
</parameter>
<parameter name="humidityCalibration" type="decimal" min="-9" max="9" groupName="calibration" unit="%">
<label>Humidity Calibration</label>
<description>Adds offset to reported humidity</description>
<advanced>true</advanced>
</parameter>
<parameter name="humidityWarningAlarm" type="boolean" groupName="alarms" required="true">
<label>Broadcast Humidity Warning</label>
<description>If enabled, the Govee device will notify openHAB if humidity is out of the specified range</description>
<default>false</default>
<advanced>true</advanced>
</parameter>
<parameter name="humidityWarningMin" type="decimal" min="0" max="100" step="0.1" groupName="alarms"
unit="%" required="true">
<label>Min Warning Humidity</label>
<description>Sets the lowest acceptable humidity value before a warning should be issued</description>
<default>0</default>
<advanced>true</advanced>
</parameter>
<parameter name="humidityWarningMax" type="decimal" min="0" max="100" step="0.1" groupName="alarms"
unit="%" required="true">
<label>Max Warning Humidity</label>
<description>Sets the highest acceptable humidity value before a warning should be issued</description>
<default>0</default>
<advanced>true</advanced>
</parameter>
</config-description>
</thing-type>
<channel-type id="govee-temperature">
<item-type>Number:Temperature</item-type>
<label>Current Measured Temperature</label>
<category>Temperature</category>
<state readOnly="true" pattern="%.1f %unit%"/>
</channel-type>
<channel-type id="govee-temperature-alarm">
<item-type>Switch</item-type>
<label>Temperature Warning Alarm</label>
<description>
If temperature warnings are enabled, then this alarm indicates whether the current temperature is out of
range.
</description>
<category>Alarm</category>
</channel-type>
<channel-type id="govee-humidity-alarm">
<item-type>Switch</item-type>
<label>Humidity Warning Alarm</label>
<description>
If humidity warnings are enabled, then this alarm indicates whether the current humidity is out of range.
</description>
<category>Alarm</category>
</channel-type>
</thing:thing-descriptions>

View File

@ -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)));
}
}

View File

@ -0,0 +1,129 @@
/**
* Copyright (c) 2010-2021 Contributors to the openHAB project
*
* See the NOTICE file(s) distributed with this work for additional
* information.
*
* This program and the accompanying materials are made available under the
* terms of the Eclipse Public License 2.0 which is available at
* http://www.eclipse.org/legal/epl-2.0
*
* SPDX-License-Identifier: EPL-2.0
*/
package org.openhab.binding.bluetooth.govee.internal.readme;
import java.io.FileInputStream;
import java.io.StringWriter;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import javax.xml.xpath.XPath;
import javax.xml.xpath.XPathConstants;
import javax.xml.xpath.XPathExpression;
import javax.xml.xpath.XPathFactory;
import org.openhab.binding.bluetooth.govee.internal.GoveeModel;
import org.w3c.dom.Document;
import org.w3c.dom.Node;
import org.w3c.dom.NodeList;
/**
* @author Connor Petty - Initial contribution
*
*/
public class ThingTypeTableGenerator {
public static void main(String[] args) throws Exception {
FileInputStream fileIS = new FileInputStream("src/main/resources/OH-INF/thing/thing-types.xml");
DocumentBuilderFactory builderFactory = DocumentBuilderFactory.newInstance();
DocumentBuilder builder = builderFactory.newDocumentBuilder();
Document xmlDocument = builder.parse(fileIS);
XPath xPath = XPathFactory.newInstance().newXPath();
String expression = "/*[local-name()='thing-descriptions']/thing-type";
XPathExpression labelExpression = xPath.compile("label/text()");
XPathExpression descriptionExpression = xPath.compile("description/text()");
NodeList nodeList = (NodeList) xPath.compile(expression).evaluate(xmlDocument, XPathConstants.NODESET);
List<ThingTypeData> thingTypeDataList = new ArrayList<>();
for (int i = 0; i < nodeList.getLength(); i++) {
Node node = nodeList.item(i);
ThingTypeData data = new ThingTypeData();
data.id = node.getAttributes().getNamedItem("id").getTextContent();
data.label = (String) labelExpression.evaluate(node, XPathConstants.STRING);
data.description = (String) descriptionExpression.evaluate(node, XPathConstants.STRING);
thingTypeDataList.add(data);
}
String[] headerRow = new String[] { "Thing Type ID", "Description", "Supported Models" };
List<String[]> rows = new ArrayList<>();
rows.add(headerRow);
rows.addAll(thingTypeDataList.stream().map(ThingTypeTableGenerator::toRow).collect(Collectors.toList()));
int[] maxColumns = { maxColumnSize(rows, 0), maxColumnSize(rows, 1), maxColumnSize(rows, 2) };
StringWriter writer = new StringWriter();
// write actual rows
rows.forEach(row -> {
writer.append(writeRow(maxColumns, row, ' ')).append('\n');
if (row == headerRow) {
writer.append(writeRow(maxColumns, new String[] { "", "", "" }, '-')).append('\n');
}
});
System.out.println(writer.toString());
}
private static String writeRow(int[] maxColumns, String[] row, char paddingChar) {
String prefix = "|" + paddingChar;
String infix = paddingChar + "|" + paddingChar;
String suffix = paddingChar + "|";
return Stream.of(0, 1, 2).map(i -> rightPad(row[i], maxColumns[i], paddingChar))
.collect(Collectors.joining(infix, prefix, suffix));
}
private static String rightPad(String str, int minLength, char paddingChar) {
if (str.length() >= minLength) {
return str;
}
StringBuilder builder = new StringBuilder(minLength);
builder.append(str);
while (builder.length() < minLength) {
builder.append(paddingChar);
}
return builder.toString();
}
private static int maxColumnSize(List<String[]> rows, int column) {
return rows.stream().map(row -> row[column].length()).max(Integer::compare).get();
}
private static class ThingTypeData {
private String id;
private String label;
private String description;
}
private static String[] toRow(ThingTypeData data) {
return new String[] { data.id, //
data.description, //
modelsForType(data.id).stream().map(model -> model.name()).collect(Collectors.joining(",")) };
}
private static List<GoveeModel> modelsForType(String typeUID) {
return Arrays.stream(GoveeModel.values()).filter(model -> model.getThingTypeUID().getId().equals(typeUID))
.collect(Collectors.toList());
}
}

View File

@ -14,4 +14,19 @@
<name>openHAB Add-ons :: Bundles :: Bluetooth Binding</name> <name>openHAB Add-ons :: Bundles :: Bluetooth Binding</name>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-jar-plugin</artifactId>
<executions>
<execution>
<goals>
<goal>test-jar</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project> </project>

View File

@ -63,6 +63,7 @@
<module>org.openhab.binding.bluetooth.daikinmadoka</module> <module>org.openhab.binding.bluetooth.daikinmadoka</module>
<module>org.openhab.binding.bluetooth.enoceanble</module> <module>org.openhab.binding.bluetooth.enoceanble</module>
<module>org.openhab.binding.bluetooth.generic</module> <module>org.openhab.binding.bluetooth.generic</module>
<module>org.openhab.binding.bluetooth.govee</module>
<module>org.openhab.binding.bluetooth.roaming</module> <module>org.openhab.binding.bluetooth.roaming</module>
<module>org.openhab.binding.bluetooth.ruuvitag</module> <module>org.openhab.binding.bluetooth.ruuvitag</module>
<module>org.openhab.binding.boschindego</module> <module>org.openhab.binding.boschindego</module>

View File

@ -12,6 +12,7 @@
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.daikinmadoka/${project.version}</bundle> <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.daikinmadoka/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.enoceanble/${project.version}</bundle> <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.enoceanble/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.generic/${project.version}</bundle> <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.generic/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.govee/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.roaming/${project.version}</bundle> <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.roaming/${project.version}</bundle>
<bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.ruuvitag/${project.version}</bundle> <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.ruuvitag/${project.version}</bundle>
</feature> </feature>