]> git.basschouten.com Git - openhab-addons.git/commitdiff
[bluetooth] Add support for RadonEye (BLE) device (#11958)
authorpetero-dk <2478689+petero-dk@users.noreply.github.com>
Mon, 27 Feb 2023 17:59:50 +0000 (18:59 +0100)
committerGitHub <noreply@github.com>
Mon, 27 Feb 2023 17:59:50 +0000 (18:59 +0100)
Signed-off-by: Peter Obel <peter@ecomerc.com>
17 files changed:
bom/openhab-addons/pom.xml
bundles/org.openhab.binding.bluetooth.radoneye/NOTICE [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.radoneye/README.md [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.radoneye/pom.xml [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.radoneye/src/main/feature/feature.xml [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/AbstractRadoneyeHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeBindingConstants.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeConfiguration.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeDataParser.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeDiscoveryParticipant.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeHandler.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeHandlerFactory.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeParserException.java [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.radoneye/src/main/resources/OH-INF/thing/radoneye.xml [new file with mode: 0644]
bundles/org.openhab.binding.bluetooth.radoneye/src/test/java/org/openhab/binding/bluetooth/radoneye/RadoneyeParserTest.java [new file with mode: 0644]
bundles/pom.xml
features/openhab-addons/src/main/resources/footer.xml

index d74ffb2408292ad370dc75fac5ba1095a62d8ac4..efe39a63389da06310ed1e448bf9f1c0173b0264 100644 (file)
       <artifactId>org.openhab.binding.bluetooth.govee</artifactId>
       <version>${project.version}</version>
     </dependency>
+    <dependency>
+      <groupId>org.openhab.addons.bundles</groupId>
+      <artifactId>org.openhab.binding.bluetooth.radoneye</artifactId>
+      <version>${project.version}</version>
+    </dependency>
     <dependency>
       <groupId>org.openhab.addons.bundles</groupId>
       <artifactId>org.openhab.binding.bluetooth.roaming</artifactId>
diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/NOTICE b/bundles/org.openhab.binding.bluetooth.radoneye/NOTICE
new file mode 100644 (file)
index 0000000..38d625e
--- /dev/null
@@ -0,0 +1,13 @@
+This content is produced and maintained by the openHAB project.
+
+* Project home: https://www.openhab.org
+
+== Declared Project Licenses
+
+This program and the accompanying materials are made available under the terms
+of the Eclipse Public License 2.0 which is available at
+https://www.eclipse.org/legal/epl-2.0/.
+
+== Source Code
+
+https://github.com/openhab/openhab-addons
diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/README.md b/bundles/org.openhab.binding.bluetooth.radoneye/README.md
new file mode 100644 (file)
index 0000000..98e9cb2
--- /dev/null
@@ -0,0 +1,47 @@
+# radoneye
+
+This extension adds support for [RadonEye](http://radonftlab.com/radon-sensor-product/radon-detector/rd200/) radon bluetooth detector. 
+
+## Supported Things
+
+Following thing types are supported by this extension:
+
+| Thing Type ID       | Description                            |
+| ------------------- | -------------------------------------- |
+| radoneye_rd200      | Original RadonEye  (RD200)             |
+
+## Discovery
+
+As any other Bluetooth device, RadonEye devices are discovered automatically by the corresponding bridge. 
+
+## Thing Configuration
+
+Supported configuration parameters for the things:
+
+| Property                        | Type    | Default | Required | Description                                                     |
+|---------------------------------|---------|---------|----------|-----------------------------------------------------------------|
+| address                         | String  |         | Yes      | Bluetooth address of the device (in format "XX:XX:XX:XX:XX:XX") |
+| refreshInterval                 | Integer | 300     | No       | How often a refresh shall occur in seconds                      |
+
+## Channels
+
+Following channels are supported for `RadonEye` thing:
+
+| Channel ID         | Item Type                | Description                                 |
+| ------------------ | ------------------------ | ------------------------------------------- |
+| radon              | Number:Density           | The measured radon level                    |
+
+
+## Example
+
+radoneye.things (assuming you have a Bluetooth bridge with the ID `bluetooth:bluegiga:adapter1`:
+
+```
+bluetooth:radoneye_rd200:adapter1:sensor1  "radoneye Wave Plus Sensor 1" (bluetooth:bluegiga:adapter1) [ address="12:34:56:78:9A:BC", refreshInterval=300 ]
+```
+
+radoneye.items:
+
+```
+Number:Density          radon    "Radon level [%d %unit%]"   { channel="bluetooth:radoneye_rd200:adapter1:sensor1:radon" }
+```
diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/pom.xml b/bundles/org.openhab.binding.bluetooth.radoneye/pom.xml
new file mode 100644 (file)
index 0000000..ad9bbd8
--- /dev/null
@@ -0,0 +1,25 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
+  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>4.0.0-SNAPSHOT</version>
+  </parent>
+
+  <artifactId>org.openhab.binding.bluetooth.radoneye</artifactId>
+
+  <name>openHAB Add-ons :: Bundles :: RadonEye 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>
+  </dependencies>
+</project>
diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/src/main/feature/feature.xml b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/feature/feature.xml
new file mode 100644 (file)
index 0000000..fa7af0f
--- /dev/null
@@ -0,0 +1,12 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<features name="org.openhab.binding.bluetooth.radoneye-${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-radoneye" description="Bluetooth Binding RadonEye" version="${project.version}">
+               <feature>openhab-runtime-base</feature>
+               <feature>openhab-transport-serial</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.radoneye/${project.version}</bundle>
+       </feature>
+
+</features>
diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/AbstractRadoneyeHandler.java b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/AbstractRadoneyeHandler.java
new file mode 100644 (file)
index 0000000..7b34978
--- /dev/null
@@ -0,0 +1,326 @@
+/**
+ * Copyright (c) 2010-2023 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.radoneye.internal;
+
+import java.util.Optional;
+import java.util.UUID;
+import java.util.concurrent.ScheduledFuture;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.atomic.AtomicInteger;
+
+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.BluetoothDevice.ConnectionState;
+import org.openhab.binding.bluetooth.BluetoothUtils;
+import org.openhab.binding.bluetooth.notification.BluetoothConnectionStatusNotification;
+import org.openhab.core.thing.Thing;
+import org.openhab.core.thing.ThingStatus;
+import org.openhab.core.thing.ThingStatusDetail;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link AbstractRadoneyeHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Peter Obel - Initial contribution
+ */
+@NonNullByDefault
+abstract public class AbstractRadoneyeHandler extends BeaconBluetoothHandler {
+
+    private static final int CHECK_PERIOD_SEC = 10;
+
+    private final Logger logger = LoggerFactory.getLogger(AbstractRadoneyeHandler.class);
+
+    private AtomicInteger sinceLastReadSec = new AtomicInteger();
+    private Optional<RadoneyeConfiguration> configuration = Optional.empty();
+    private @Nullable ScheduledFuture<?> scheduledTask;
+
+    private volatile int refreshInterval;
+    private volatile int errorConnectCounter;
+    private volatile int errorReadCounter;
+    private volatile int errorWriteCounter;
+    private volatile int errorDisconnectCounter;
+    private volatile int errorResolvingCounter;
+
+    private volatile ServiceState serviceState = ServiceState.NOT_RESOLVED;
+    private volatile ReadState readState = ReadState.IDLE;
+
+    private enum ServiceState {
+        NOT_RESOLVED,
+        RESOLVING,
+        RESOLVED,
+    }
+
+    private enum ReadState {
+        IDLE,
+        READING,
+        WRITING,
+    }
+
+    public AbstractRadoneyeHandler(Thing thing) {
+        super(thing);
+    }
+
+    @Override
+    public void initialize() {
+        logger.debug("Initialize");
+        super.initialize();
+        configuration = Optional.of(getConfigAs(RadoneyeConfiguration.class));
+        logger.debug("Using configuration: {}", configuration.get());
+        cancelScheduledTask();
+        configuration.ifPresent(cfg -> {
+            refreshInterval = cfg.refreshInterval;
+            logger.debug("Start scheduled task to read device in every {} seconds", refreshInterval);
+            scheduledTask = scheduler.scheduleWithFixedDelay(this::executePeridioc, CHECK_PERIOD_SEC, CHECK_PERIOD_SEC,
+                    TimeUnit.SECONDS);
+        });
+        sinceLastReadSec.set(refreshInterval); // update immediately
+    }
+
+    @Override
+    public void dispose() {
+        logger.debug("Dispose");
+        cancelScheduledTask();
+        serviceState = ServiceState.NOT_RESOLVED;
+        readState = ReadState.IDLE;
+        super.dispose();
+    }
+
+    private void cancelScheduledTask() {
+        if (scheduledTask != null) {
+            scheduledTask.cancel(true);
+            scheduledTask = null;
+        }
+    }
+
+    private void executePeridioc() {
+        sinceLastReadSec.addAndGet(CHECK_PERIOD_SEC);
+        execute();
+    }
+
+    private synchronized void execute() {
+        ConnectionState connectionState = device.getConnectionState();
+        logger.debug("Device {} state is {}, serviceState {}, readState {}", address, connectionState, serviceState,
+                readState);
+
+        switch (connectionState) {
+            case DISCOVERING:
+            case DISCOVERED:
+            case DISCONNECTED:
+                if (isTimeToRead()) {
+                    connect();
+                }
+                break;
+            case CONNECTED:
+                read();
+                break;
+            default:
+                break;
+        }
+    }
+
+    private void connect() {
+        logger.debug("Connect to device {}...", address);
+        if (!device.connect()) {
+            errorConnectCounter++;
+            if (errorConnectCounter < 6) {
+                logger.debug("Connecting to device {} failed {} times", address, errorConnectCounter);
+            } else {
+                logger.debug("ERROR:  Controller reset needed.  Connecting to device {} failed {} times", address,
+                        errorConnectCounter);
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Connecting to device failed");
+            }
+        } else {
+            logger.debug("Connected to device {}", address);
+            errorConnectCounter = 0;
+        }
+    }
+
+    private void disconnect() {
+        logger.debug("Disconnect from device {}...", address);
+        if (!device.disconnect()) {
+            errorDisconnectCounter++;
+            if (errorDisconnectCounter < 6) {
+                logger.debug("Disconnect from device {} failed {} times", address, errorDisconnectCounter);
+            } else {
+                logger.debug("ERROR:  Controller reset needed.  Disconnect from device {} failed {} times", address,
+                        errorDisconnectCounter);
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                        "Disconnect from device failed");
+            }
+        } else {
+            logger.debug("Disconnected from device {}", address);
+            errorDisconnectCounter = 0;
+        }
+    }
+
+    private void read() {
+        switch (serviceState) {
+            case NOT_RESOLVED:
+                logger.debug("Discover services on device {}", address);
+                discoverServices();
+                break;
+            case RESOLVED:
+                switch (readState) {
+                    case IDLE:
+                        if (getTriggerUUID() != null) {
+                            logger.debug("Send trigger data to device {}...", address);
+                            BluetoothCharacteristic characteristic = device.getCharacteristic(getTriggerUUID());
+                            if (characteristic != null) {
+                                readState = ReadState.WRITING;
+                                errorWriteCounter = 0;
+                                device.writeCharacteristic(characteristic, getTriggerData()).whenComplete((v, ex) -> {
+                                    readSensorData();
+                                });
+                            } else {
+                                errorWriteCounter++;
+                                if (errorWriteCounter < 6) {
+                                    logger.debug("Read/write data from device {} failed {} times", address,
+                                            errorWriteCounter);
+                                } else {
+                                    logger.debug(
+                                            "ERROR:  Controller reset needed.  Read/write data from device {} failed {} times",
+                                            address, errorWriteCounter);
+                                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                                            "Read/write data from device failed");
+                                }
+                                disconnect();
+                            }
+                        } else {
+                            readSensorData();
+                        }
+
+                        break;
+                    default:
+                        logger.debug("Unhandled Resolved readState {} on device {}", readState, address);
+                        break;
+                }
+                break;
+            default: // serviceState RESOLVING
+                errorResolvingCounter++;
+                if (errorResolvingCounter < 6) {
+                    logger.debug("Unhandled serviceState {} on device {}", serviceState, address);
+                } else {
+                    logger.debug("ERROR:  Controller reset needed.  Unhandled serviceState {} on device {}",
+                            serviceState, address);
+                    updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                            "Service discovery for device failed");
+                }
+                break;
+        }
+    }
+
+    private void readSensorData() {
+        logger.debug("Read data from device {}...", address);
+        BluetoothCharacteristic characteristic = device.getCharacteristic(getDataUUID());
+        if (characteristic != null) {
+            readState = ReadState.READING;
+            errorReadCounter = 0;
+            errorResolvingCounter = 0;
+            device.readCharacteristic(characteristic).whenComplete((data, ex) -> {
+                try {
+                    logger.debug("Characteristic {} from device {}: {}", characteristic.getUuid(), address, data);
+                    updateStatus(ThingStatus.ONLINE);
+                    sinceLastReadSec.set(0);
+                    updateChannels(BluetoothUtils.toIntArray(data));
+                } finally {
+                    readState = ReadState.IDLE;
+                    disconnect();
+                }
+            });
+        } else {
+            errorReadCounter++;
+            if (errorReadCounter < 6) {
+                logger.debug("Read data from device {} failed {} times", address, errorReadCounter);
+            } else {
+                logger.debug("ERROR:  Controller reset needed.  Read data from device {} failed {} times", address,
+                        errorReadCounter);
+                updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
+                        "Read data from device failed");
+            }
+            disconnect();
+        }
+    }
+
+    private void discoverServices() {
+        logger.debug("Discover services for device {}", address);
+        serviceState = ServiceState.RESOLVING;
+        device.discoverServices();
+    }
+
+    @Override
+    public void onServicesDiscovered() {
+        serviceState = ServiceState.RESOLVED;
+        logger.debug("Service discovery completed for device {}", address);
+        printServices();
+        execute();
+    }
+
+    private void printServices() {
+        device.getServices().forEach(service -> logger.debug("Device {} Service '{}'", address, service));
+    }
+
+    @Override
+    public void onConnectionStateChange(BluetoothConnectionStatusNotification connectionNotification) {
+        logger.debug("Connection State Change Event is {}", connectionNotification.getConnectionState());
+        switch (connectionNotification.getConnectionState()) {
+            case DISCONNECTED:
+                if (serviceState == ServiceState.RESOLVING) {
+                    serviceState = ServiceState.NOT_RESOLVED;
+                }
+                readState = ReadState.IDLE;
+                break;
+            default:
+                break;
+
+        }
+        execute();
+    }
+
+    private boolean isTimeToRead() {
+        int sinceLastRead = sinceLastReadSec.get();
+        logger.debug("Time since last update: {} sec", sinceLastRead);
+        return sinceLastRead >= refreshInterval;
+    }
+
+    /**
+     * Provides the UUID of the characteristic, which holds the sensor data
+     *
+     * @return the UUID of the data characteristic
+     */
+    protected abstract UUID getDataUUID();
+
+    /**
+     * Provides the UUID of the characteristic, that triggers and update of the sensor data
+     *
+     * @return the UUID of the data characteristic
+     */
+    protected abstract UUID getTriggerUUID();
+
+    /**
+     * Provides the data that sent to the trigger characteristic will update the sensor data
+     *
+     * @return the trigger data as an byte array
+     */
+    protected abstract byte[] getTriggerData();
+
+    /**
+     * This method parses the content of the bluetooth characteristic and updates the Thing channels accordingly.
+     *
+     * @param is the content of the bluetooth characteristic
+     */
+    abstract protected void updateChannels(int[] is);
+}
diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeBindingConstants.java b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeBindingConstants.java
new file mode 100644 (file)
index 0000000..bfb97ab
--- /dev/null
@@ -0,0 +1,60 @@
+/**
+ * Copyright (c) 2010-2023 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.radoneye.internal;
+
+import java.math.BigInteger;
+import java.util.Set;
+
+import javax.measure.Unit;
+import javax.measure.quantity.Dimensionless;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.binding.bluetooth.BluetoothBindingConstants;
+import org.openhab.core.library.dimension.Density;
+import org.openhab.core.library.unit.SIUnits;
+import org.openhab.core.library.unit.Units;
+import org.openhab.core.thing.ThingTypeUID;
+
+import tech.units.indriya.format.SimpleUnitFormat;
+import tech.units.indriya.function.MultiplyConverter;
+import tech.units.indriya.unit.ProductUnit;
+import tech.units.indriya.unit.TransformedUnit;
+
+/**
+ * The {@link RadoneyeBindingConstants} class defines common constants, which are
+ * used across the whole binding.
+ *
+ * @author Peter Obel - Initial contribution
+ */
+@NonNullByDefault
+public class RadoneyeBindingConstants {
+
+    // List of all Thing Type UIDs
+    public static final ThingTypeUID THING_TYPE_RADONEYE = new ThingTypeUID(BluetoothBindingConstants.BINDING_ID,
+            "radoneye_rd200");
+
+    public static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_RADONEYE);
+
+    // Channel IDs
+    public static final String CHANNEL_ID_RADON = "radon";
+
+    public static final Unit<Dimensionless> PARTS_PER_BILLION = new TransformedUnit<>(Units.ONE,
+            MultiplyConverter.ofRational(BigInteger.ONE, BigInteger.valueOf(1000000000)));
+    public static final Unit<Density> BECQUEREL_PER_CUBIC_METRE = new ProductUnit<>(
+            Units.BECQUEREL.divide(SIUnits.CUBIC_METRE));
+
+    static {
+        SimpleUnitFormat.getInstance().label(PARTS_PER_BILLION, "ppb");
+        SimpleUnitFormat.getInstance().label(BECQUEREL_PER_CUBIC_METRE, "Bq/m³");
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeConfiguration.java b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeConfiguration.java
new file mode 100644 (file)
index 0000000..5ee2d7a
--- /dev/null
@@ -0,0 +1,31 @@
+/**
+ * Copyright (c) 2010-2023 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.radoneye.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Configuration class for {@link RadoneyeBinding} device.
+ *
+ * @author Peter Obel - Initial contribution
+ */
+@NonNullByDefault
+public class RadoneyeConfiguration {
+    public String address = "";
+    public int refreshInterval;
+
+    @Override
+    public String toString() {
+        return "[address=" + address + ", refreshInterval=" + refreshInterval + "]";
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeDataParser.java b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeDataParser.java
new file mode 100644 (file)
index 0000000..730834a
--- /dev/null
@@ -0,0 +1,76 @@
+/**
+ * Copyright (c) 2010-2023 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.radoneye.internal;
+
+import java.math.BigDecimal;
+import java.util.Arrays;
+import java.util.HashMap;
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link RadoneyeDataParser} is responsible for parsing data from Wave Plus device format.
+ *
+ * @author Peter Obel - Initial contribution
+ */
+@NonNullByDefault
+public class RadoneyeDataParser {
+    public static final String RADON = "radon";
+
+    private static final int EXPECTED_DATA_LEN = 20;
+    private static final int EXPECTED_VER_PLUS = 1;
+
+    private static final Logger logger = LoggerFactory.getLogger(RadoneyeDataParser.class);
+
+    private RadoneyeDataParser() {
+    }
+
+    public static Map<String, Number> parseRd200Data(int[] data) throws RadoneyeParserException {
+        logger.debug("Parsed data length: {}", data.length);
+        logger.debug("Parsed data: {}", data);
+        if (data.length == EXPECTED_DATA_LEN) {
+            final Map<String, Number> result = new HashMap<>();
+
+            int[] radonArray = subArray(data, 2, 6);
+            result.put(RADON, new BigDecimal(readFloat(radonArray) * 37));
+            return result;
+        } else {
+            throw new RadoneyeParserException(String.format("Illegal data structure length '%d'", data.length));
+        }
+    }
+
+    private static int intFromBytes(int lowByte, int highByte) {
+        return (highByte & 0xFF) << 8 | (lowByte & 0xFF);
+    }
+
+    // Little endian
+    private static int fromByteArrayLE(int[] bytes) {
+        int result = 0;
+        for (int i = 0; i < bytes.length; i++) {
+            result |= (bytes[i] & 0xFF) << (8 * i);
+        }
+        return result;
+    }
+
+    private static float readFloat(int[] bytes) {
+        int i = fromByteArrayLE(bytes);
+        return Float.intBitsToFloat(i);
+    }
+
+    private static int[] subArray(int[] array, int beg, int end) {
+        return Arrays.copyOfRange(array, beg, end + 1);
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeDiscoveryParticipant.java b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeDiscoveryParticipant.java
new file mode 100644 (file)
index 0000000..decf694
--- /dev/null
@@ -0,0 +1,155 @@
+/**
+ * Copyright (c) 2010-2023 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.radoneye.internal;
+
+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;
+
+/**
+ * This discovery participant is able to recognize RadonEye devices and create discovery results for them.
+ *
+ * @author Peter Obel - Initial contribution
+ *
+ */
+@NonNullByDefault
+@Component
+public class RadoneyeDiscoveryParticipant implements BluetoothDiscoveryParticipant {
+
+    private static final String RADONEYE_BLUETOOTH_COMPANY_ID = "f24be3";
+
+    private static final String RD200 = "R20"; // RadonEye First Generation BLE
+
+    @Override
+    public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
+        return RadoneyeBindingConstants.SUPPORTED_THING_TYPES_UIDS;
+    }
+
+    @Override
+    public @Nullable ThingUID getThingUID(BluetoothDiscoveryDevice device) {
+        if (isRadoneyeDevice(device)) {
+            if (RD200.equals(getModel(device))) {
+                return new ThingUID(RadoneyeBindingConstants.THING_TYPE_RADONEYE, device.getAdapter().getUID(),
+                        device.getAddress().toString().toLowerCase().replace(":", ""));
+            }
+        }
+        return null;
+    }
+
+    @Override
+    public @Nullable DiscoveryResult createResult(BluetoothDiscoveryDevice device) {
+        if (!isRadoneyeDevice(device)) {
+            return null;
+        }
+        ThingUID thingUID = getThingUID(device);
+        if (thingUID == null) {
+            return null;
+        }
+        if (RD200.equals(getModel(device))) {
+            return createResult(device, thingUID, "RadonEye (BLE)");
+        }
+        return null;
+    }
+
+    @Override
+    public boolean requiresConnection(BluetoothDiscoveryDevice device) {
+        return isRadoneyeDevice(device);
+    }
+
+    private boolean isRadoneyeDevice(BluetoothDiscoveryDevice device) {
+        String manufacturerMacId = device.getAddress().toString().toLowerCase().replace(":", "").substring(0, 6);
+        if (manufacturerMacId.equals(RADONEYE_BLUETOOTH_COMPANY_ID.toLowerCase())) {
+            return true;
+        }
+        return false;
+    }
+
+    private String getSerial(BluetoothDiscoveryDevice device) {
+        String name = device.getName();
+        String[] parts = name.split(":");
+        if (parts.length == 3) {
+            return parts[2];
+        } else {
+            return "";
+        }
+    }
+
+    private String getManufacturer(BluetoothDiscoveryDevice device) {
+        String name = device.getName();
+        String[] parts = name.split(":");
+        if (parts.length == 3) {
+            return parts[0];
+        } else {
+            return "";
+        }
+    }
+
+    private String getModel(BluetoothDiscoveryDevice device) {
+        String name = device.getName();
+        String[] parts = name.split(":");
+        if (parts.length == 3) {
+            return parts[1];
+        } else {
+            return "";
+        }
+    }
+
+    private DiscoveryResult createResult(BluetoothDiscoveryDevice device, ThingUID thingUID, String label) {
+        Map<String, Object> properties = new HashMap<>();
+        properties.put(BluetoothBindingConstants.CONFIGURATION_ADDRESS, device.getAddress().toString());
+        properties.put(Thing.PROPERTY_VENDOR, "RadonEye");
+        String name = device.getName();
+        String serialNumber = device.getSerialNumber();
+        String firmwareRevision = device.getFirmwareRevision();
+        String model = device.getModel();
+        String hardwareRevision = device.getHardwareRevision();
+        Integer txPower = device.getTxPower();
+        if (serialNumber != null) {
+            properties.put(Thing.PROPERTY_SERIAL_NUMBER, serialNumber);
+        } else {
+            properties.put(Thing.PROPERTY_MODEL_ID, getSerial(device));
+        }
+        if (firmwareRevision != null) {
+            properties.put(Thing.PROPERTY_FIRMWARE_VERSION, firmwareRevision);
+        }
+        if (model != null) {
+            properties.put(Thing.PROPERTY_MODEL_ID, model);
+        } else {
+            properties.put(Thing.PROPERTY_MODEL_ID, getModel(device));
+        }
+        if (hardwareRevision != null) {
+            properties.put(Thing.PROPERTY_HARDWARE_VERSION, hardwareRevision);
+        }
+        if (txPower != null) {
+            properties.put(BluetoothBindingConstants.PROPERTY_TXPOWER, Integer.toString(txPower));
+        }
+        properties.put(Thing.PROPERTY_MAC_ADDRESS, device.getAddress().toString());
+
+        // Create the discovery result and add to the inbox
+        return DiscoveryResultBuilder.create(thingUID).withProperties(properties)
+                .withRepresentationProperty(BluetoothBindingConstants.CONFIGURATION_ADDRESS)
+                .withBridge(device.getAdapter().getUID()).withLabel(label).build();
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeHandler.java b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeHandler.java
new file mode 100644 (file)
index 0000000..a5b36a8
--- /dev/null
@@ -0,0 +1,80 @@
+/**
+ * Copyright (c) 2010-2023 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.radoneye.internal;
+
+import static org.openhab.binding.bluetooth.radoneye.internal.RadoneyeBindingConstants.*;
+
+import java.util.Map;
+import java.util.UUID;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.openhab.core.library.dimension.Density;
+import org.openhab.core.library.types.QuantityType;
+import org.openhab.core.thing.Thing;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+/**
+ * The {@link RadoneyeHandler} is responsible for handling commands, which are
+ * sent to one of the channels.
+ *
+ * @author Peter Obel - Initial contribution
+ */
+@NonNullByDefault
+public class RadoneyeHandler extends AbstractRadoneyeHandler {
+
+    private static final String SERVICE_UUID = "00001523-1212-efde-1523-785feabcd123";
+    private static final String TRIGGER_UID = "00001524-1212-efde-1523-785feabcd123";
+    private static final String DATA_UUID = "00001525-1212-efde-1523-785feabcd123";
+
+    public RadoneyeHandler(Thing thing) {
+        super(thing);
+    }
+
+    private final Logger logger = LoggerFactory.getLogger(RadoneyeHandler.class);
+
+    private final UUID dataUuid = UUID.fromString(DATA_UUID);
+    private final UUID triggerUuid = UUID.fromString(TRIGGER_UID);
+    private final byte[] triggerData = new byte[] { 0x50 };
+
+    @Override
+    protected void updateChannels(int[] is) {
+        Map<String, Number> data;
+        try {
+            data = RadoneyeDataParser.parseRd200Data(is);
+            logger.debug("Parsed data: {}", data);
+            Number radon = data.get(RadoneyeDataParser.RADON);
+            logger.debug("Parsed data radon number: {}", radon);
+            if (radon != null) {
+                updateState(CHANNEL_ID_RADON, new QuantityType<Density>(radon, BECQUEREL_PER_CUBIC_METRE));
+            }
+        } catch (RadoneyeParserException e) {
+            logger.error("Failed to parse data received from Radoneye sensor: {}", e.getMessage());
+        }
+    }
+
+    @Override
+    protected UUID getDataUUID() {
+        return dataUuid;
+    }
+
+    @Override
+    protected UUID getTriggerUUID() {
+        return triggerUuid;
+    }
+
+    @Override
+    protected byte[] getTriggerData() {
+        return triggerData;
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeHandlerFactory.java b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeHandlerFactory.java
new file mode 100644 (file)
index 0000000..950ddcc
--- /dev/null
@@ -0,0 +1,46 @@
+/**
+ * Copyright (c) 2010-2023 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.radoneye.internal;
+
+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 RadoneyeHandlerFactory} is responsible for creating things and thing handlers.
+ *
+ * @author Peter Obel - Initial contribution
+ */
+@NonNullByDefault
+@Component(service = ThingHandlerFactory.class, configurationPid = "binding.radoneye")
+public class RadoneyeHandlerFactory extends BaseThingHandlerFactory {
+
+    @Override
+    public boolean supportsThingType(ThingTypeUID thingTypeUID) {
+        return RadoneyeBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
+    }
+
+    @Override
+    protected @Nullable ThingHandler createHandler(Thing thing) {
+        ThingTypeUID thingTypeUID = thing.getThingTypeUID();
+        if (thingTypeUID.equals(RadoneyeBindingConstants.THING_TYPE_RADONEYE)) {
+            return new RadoneyeHandler(thing);
+        }
+        return null;
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeParserException.java b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeParserException.java
new file mode 100644 (file)
index 0000000..b8861f1
--- /dev/null
@@ -0,0 +1,41 @@
+/**
+ * Copyright (c) 2010-2023 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.radoneye.internal;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+
+/**
+ * Exception for data parsing errors.
+ *
+ * @author Peter Obel - Initial contribution
+ */
+@NonNullByDefault
+public class RadoneyeParserException extends Exception {
+
+    private static final long serialVersionUID = 1;
+
+    public RadoneyeParserException() {
+    }
+
+    public RadoneyeParserException(String message) {
+        super(message);
+    }
+
+    public RadoneyeParserException(String message, Throwable cause) {
+        super(message, cause);
+    }
+
+    public RadoneyeParserException(Throwable cause) {
+        super(cause);
+    }
+}
diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/src/main/resources/OH-INF/thing/radoneye.xml b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/resources/OH-INF/thing/radoneye.xml
new file mode 100644 (file)
index 0000000..4992c2a
--- /dev/null
@@ -0,0 +1,41 @@
+<?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="radoneye_rd200">
+               <supported-bridge-type-refs>
+                       <bridge-type-ref id="roaming"/>
+                       <bridge-type-ref id="bluegiga"/>
+                       <bridge-type-ref id="bluez"/>
+               </supported-bridge-type-refs>
+
+               <label>RadonEye RD200</label>
+               <description>Indoor radon monitor</description>
+
+               <channels>
+                       <channel id="rssi" typeId="rssi"/>
+                       <channel id="radon" typeId="radoneye_radon"/>
+               </channels>
+
+               <config-description>
+                       <parameter name="address" type="text">
+                               <label>Address</label>
+                               <description>Bluetooth address in XX:XX:XX:XX:XX:XX format</description>
+                       </parameter>
+                       <parameter name="refreshInterval" type="integer" min="10">
+                               <label>Refresh Interval</label>
+                               <description>States how often a refresh shall occur in seconds. This could have impact to battery lifetime</description>
+                               <default>300</default>
+                       </parameter>
+               </config-description>
+       </thing-type>
+
+       <channel-type id="radoneye_radon">
+               <item-type>Number:Density</item-type>
+               <label>Radon Current Level</label>
+               <description>Radon gas level</description>
+               <state readOnly="true" pattern="%.0f %unit%"/>
+       </channel-type>
+</thing:thing-descriptions>
diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/src/test/java/org/openhab/binding/bluetooth/radoneye/RadoneyeParserTest.java b/bundles/org.openhab.binding.bluetooth.radoneye/src/test/java/org/openhab/binding/bluetooth/radoneye/RadoneyeParserTest.java
new file mode 100644 (file)
index 0000000..47b0c68
--- /dev/null
@@ -0,0 +1,51 @@
+/**
+ * Copyright (c) 2010-2023 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.radoneye;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+import java.util.Map;
+
+import org.eclipse.jdt.annotation.NonNullByDefault;
+import org.junit.jupiter.api.Test;
+import org.openhab.binding.bluetooth.radoneye.internal.RadoneyeDataParser;
+import org.openhab.binding.bluetooth.radoneye.internal.RadoneyeParserException;
+
+/**
+ * Tests {@link RadoneyeParserTest}.
+ *
+ * @author Peter Obel - Initial contribution
+ */
+@NonNullByDefault
+public class RadoneyeParserTest {
+
+    @Test
+    public void testEmptyData() {
+        int[] data = {};
+        assertThrows(RadoneyeParserException.class, () -> RadoneyeDataParser.parseRd200Data(data));
+    }
+
+    @Test
+    public void testWrongDataLen() throws RadoneyeParserException {
+        int[] data = { 1, 55, 51, 0, 122, 0, 61, 0, 119, 9, 11, 194, 169, 2, 46, 0, 0 };
+        assertThrows(RadoneyeParserException.class, () -> RadoneyeDataParser.parseRd200Data(data));
+    }
+
+    @Test
+    public void testParsingRd200() throws RadoneyeParserException {
+        int[] data = { 80, 16, 31, -123, 43, 64, 123, 20, 94, 64, 92, -113, -118, 64, 15, 0, 12, 0, 0, 0 };
+        Map<String, Number> result = RadoneyeDataParser.parseRd200Data(data);
+
+        assertEquals(99, result.get(RadoneyeDataParser.RADON).intValue());
+    }
+}
index acbacfc1bec2bce0b852e08fa9751ce16fb528af..e22d555988c616ee823a616167cb7e4bbfbda7a7 100644 (file)
@@ -75,6 +75,7 @@
     <module>org.openhab.binding.bluetooth.enoceanble</module>
     <module>org.openhab.binding.bluetooth.generic</module>
     <module>org.openhab.binding.bluetooth.govee</module>
+    <module>org.openhab.binding.bluetooth.radoneye</module>
     <module>org.openhab.binding.bluetooth.roaming</module>
     <module>org.openhab.binding.bluetooth.ruuvitag</module>
     <module>org.openhab.binding.bondhome</module>
index d38fbdf3ff242cfa8c5cc032e282a1837176679d..61351094f07f197d08fa677c0bfaf29809556d34 100644 (file)
@@ -13,6 +13,7 @@
                <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.enoceanble/${project.version}</bundle>
                <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.generic/${project.version}</bundle>
                <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.govee/${project.version}</bundle>
+               <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.radoneye/${project.version}</bundle>
                <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.roaming/${project.version}</bundle>
                <bundle start-level="80">mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.ruuvitag/${project.version}</bundle>
        </feature>