diff --git a/bom/openhab-addons/pom.xml b/bom/openhab-addons/pom.xml index d74ffb24082..efe39a63389 100644 --- a/bom/openhab-addons/pom.xml +++ b/bom/openhab-addons/pom.xml @@ -206,6 +206,11 @@ org.openhab.binding.bluetooth.govee ${project.version} + + org.openhab.addons.bundles + org.openhab.binding.bluetooth.radoneye + ${project.version} + org.openhab.addons.bundles org.openhab.binding.bluetooth.roaming diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/NOTICE b/bundles/org.openhab.binding.bluetooth.radoneye/NOTICE new file mode 100644 index 00000000000..38d625e3492 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.radoneye/NOTICE @@ -0,0 +1,13 @@ +This content is produced and maintained by the openHAB project. + +* Project home: https://www.openhab.org + +== Declared Project Licenses + +This program and the accompanying materials are made available under the terms +of the Eclipse Public License 2.0 which is available at +https://www.eclipse.org/legal/epl-2.0/. + +== Source Code + +https://github.com/openhab/openhab-addons diff --git a/bundles/org.openhab.binding.bluetooth.radoneye/README.md b/bundles/org.openhab.binding.bluetooth.radoneye/README.md new file mode 100644 index 00000000000..98e9cb2c5b2 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.radoneye/README.md @@ -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 index 00000000000..ad9bbd82395 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.radoneye/pom.xml @@ -0,0 +1,25 @@ + + + + 4.0.0 + + + org.openhab.addons.bundles + org.openhab.addons.reactor.bundles + 4.0.0-SNAPSHOT + + + org.openhab.binding.bluetooth.radoneye + + openHAB Add-ons :: Bundles :: RadonEye Bluetooth Adapter + + + + org.openhab.addons.bundles + org.openhab.binding.bluetooth + ${project.version} + provided + + + 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 index 00000000000..fa7af0f740e --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/feature/feature.xml @@ -0,0 +1,12 @@ + + + mvn:org.openhab.core.features.karaf/org.openhab.core.features.karaf.openhab-core/${ohc.version}/xml/features + + + openhab-runtime-base + openhab-transport-serial + mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth/${project.version} + mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.radoneye/${project.version} + + + 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 index 00000000000..7b3497865fa --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/AbstractRadoneyeHandler.java @@ -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 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 index 00000000000..bfb97abe1f5 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeBindingConstants.java @@ -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 SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_RADONEYE); + + // Channel IDs + public static final String CHANNEL_ID_RADON = "radon"; + + public static final Unit PARTS_PER_BILLION = new TransformedUnit<>(Units.ONE, + MultiplyConverter.ofRational(BigInteger.ONE, BigInteger.valueOf(1000000000))); + public static final Unit 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 index 00000000000..5ee2d7a2f55 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeConfiguration.java @@ -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 index 00000000000..730834a40ae --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeDataParser.java @@ -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 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 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 index 00000000000..decf69417ae --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeDiscoveryParticipant.java @@ -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 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 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 index 00000000000..a5b36a8b0f7 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeHandler.java @@ -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 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(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 index 00000000000..950ddcc65e3 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeHandlerFactory.java @@ -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 index 00000000000..b8861f1051c --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/java/org/openhab/binding/bluetooth/radoneye/internal/RadoneyeParserException.java @@ -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 index 00000000000..4992c2a9c59 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.radoneye/src/main/resources/OH-INF/thing/radoneye.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + Indoor radon monitor + + + + + + + + + + Bluetooth address in XX:XX:XX:XX:XX:XX format + + + + States how often a refresh shall occur in seconds. This could have impact to battery lifetime + 300 + + + + + + Number:Density + + Radon gas level + + + 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 index 00000000000..47b0c68f0c7 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.radoneye/src/test/java/org/openhab/binding/bluetooth/radoneye/RadoneyeParserTest.java @@ -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 result = RadoneyeDataParser.parseRd200Data(data); + + assertEquals(99, result.get(RadoneyeDataParser.RADON).intValue()); + } +} diff --git a/bundles/pom.xml b/bundles/pom.xml index acbacfc1bec..e22d555988c 100644 --- a/bundles/pom.xml +++ b/bundles/pom.xml @@ -75,6 +75,7 @@ org.openhab.binding.bluetooth.enoceanble org.openhab.binding.bluetooth.generic org.openhab.binding.bluetooth.govee + org.openhab.binding.bluetooth.radoneye org.openhab.binding.bluetooth.roaming org.openhab.binding.bluetooth.ruuvitag org.openhab.binding.bondhome diff --git a/features/openhab-addons/src/main/resources/footer.xml b/features/openhab-addons/src/main/resources/footer.xml index d38fbdf3ff2..61351094f07 100644 --- a/features/openhab-addons/src/main/resources/footer.xml +++ b/features/openhab-addons/src/main/resources/footer.xml @@ -13,6 +13,7 @@ mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.enoceanble/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.generic/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.govee/${project.version} + mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.radoneye/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.roaming/${project.version} mvn:org.openhab.addons.bundles/org.openhab.binding.bluetooth.ruuvitag/${project.version}