From 38a532abaa71f4823249354e9150721eddbae631 Mon Sep 17 00:00:00 2001 From: Kai Kreuzer Date: Fri, 16 Apr 2021 21:38:20 +0200 Subject: [PATCH] [airthings] Add support for Airthings Wave Mini (#10456) Signed-off-by: Kai Kreuzer --- .../README.md | 15 +- .../internal/AbstractAirthingsHandler.java | 233 ++++++++++++++++ .../internal/AirthingsBindingConstants.java | 7 + .../internal/AirthingsDataParser.java | 85 ++++++ .../AirthingsDiscoveryParticipant.java | 18 +- .../internal/AirthingsHandlerFactory.java | 12 +- .../internal/AirthingsWaveMiniHandler.java | 77 ++++++ .../internal/AirthingsWavePlusDataParser.java | 97 ------- .../internal/AirthingsWavePlusHandler.java | 255 ++++-------------- .../main/resources/OH-INF/thing/airthings.xml | 35 ++- .../airthings/AirthingsParserTest.java | 73 +++++ .../AirthingsWavePlusParserTest.java | 61 ----- 12 files changed, 586 insertions(+), 382 deletions(-) create mode 100644 bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AbstractAirthingsHandler.java create mode 100644 bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsDataParser.java create mode 100644 bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsWaveMiniHandler.java delete mode 100644 bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsWavePlusDataParser.java create mode 100644 bundles/org.openhab.binding.bluetooth.airthings/src/test/java/org/openhab/binding/bluetooth/airthings/AirthingsParserTest.java delete mode 100644 bundles/org.openhab.binding.bluetooth.airthings/src/test/java/org/openhab/binding/bluetooth/airthings/AirthingsWavePlusParserTest.java diff --git a/bundles/org.openhab.binding.bluetooth.airthings/README.md b/bundles/org.openhab.binding.bluetooth.airthings/README.md index eecba58d1e9d8..fe97bbc46917f 100644 --- a/bundles/org.openhab.binding.bluetooth.airthings/README.md +++ b/bundles/org.openhab.binding.bluetooth.airthings/README.md @@ -8,7 +8,8 @@ Following thing types are supported by this extension: | Thing Type ID | Description | | ------------------- | ------------------------- | -| airthings_wave_plus | Airthings Wave+ | +| airthings_wave_plus | Airthings Wave Plus | +| airthings_wave_mini | Airthings Wave Mini | ## Discovery @@ -17,7 +18,7 @@ As any other Bluetooth device, Airthings devices are discovered automatically by ## Thing Configuration -Supported configuration parameters for `Airthings Wave+` thing: +Supported configuration parameters for the things: | Property | Type | Default | Required | Description | |---------------------------------|---------|---------|----------|-----------------------------------------------------------------| @@ -26,18 +27,24 @@ Supported configuration parameters for `Airthings Wave+` thing: ## Channels -Following channels are supported for `Airthings Wave+` thing: +Following channels are supported for `Airthings Wave Mini` thing: | Channel ID | Item Type | Description | | ------------------ | ------------------------ | ------------------------------------------- | | temperature | Number:Temperature | The measured temperature | | humidity | Number:Dimensionless | The measured humidity | +| tvoc | Number:Dimensionless | The measured TVOC level | + +The `Airthings Wave Plus` thing has additionally the following channels: + +| Channel ID | Item Type | Description | +| ------------------ | ------------------------ | ------------------------------------------- | | pressure | Number:Pressure | The measured air pressure | | co2 | Number:Dimensionless | The measured CO2 level | -| tvoc | Number:Dimensionless | The measured TVOC level | | radon_st_avg | Number:Density | The measured radon short term average level | | radon_lt_avg | Number:Density | The measured radon long term average level | + ## Example airthings.things (assuming you have a Bluetooth bridge with the ID `bluetooth:bluegiga:adapter1`: diff --git a/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AbstractAirthingsHandler.java b/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AbstractAirthingsHandler.java new file mode 100644 index 0000000000000..51b30d8150ec8 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AbstractAirthingsHandler.java @@ -0,0 +1,233 @@ +/** + * 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.airthings.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.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AbstractAirthingsHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Pauli Anttila - Initial contribution + * @author Kai Kreuzer - Added Airthings Wave Mini support + */ +@NonNullByDefault +abstract public class AbstractAirthingsHandler extends BeaconBluetoothHandler { + + private static final int CHECK_PERIOD_SEC = 10; + + private final Logger logger = LoggerFactory.getLogger(AbstractAirthingsHandler.class); + + private AtomicInteger sinceLastReadSec = new AtomicInteger(); + private Optional configuration = Optional.empty(); + private @Nullable ScheduledFuture scheduledTask; + + private volatile int refreshInterval; + + 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, + } + + public AbstractAirthingsHandler(Thing thing) { + super(thing); + } + + @Override + public void initialize() { + logger.debug("Initialize"); + super.initialize(); + configuration = Optional.of(getConfigAs(AirthingsConfiguration.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()) { + logger.debug("Connecting to device {} failed", address); + } + } + + private void disconnect() { + logger.debug("Disconnect from device {}...", address); + if (!device.disconnect()) { + logger.debug("Disconnect from device {} failed", address); + } + } + + private void read() { + switch (serviceState) { + case NOT_RESOLVED: + discoverServices(); + break; + case RESOLVED: + switch (readState) { + case IDLE: + logger.debug("Read data from device {}...", address); + BluetoothCharacteristic characteristic = device.getCharacteristic(getDataUUID()); + if (characteristic != null) { + readState = ReadState.READING; + 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 { + logger.debug("Read data from device {} failed", address); + disconnect(); + } + break; + default: + break; + } + default: + break; + } + } + + 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) { + 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(); + + /** + * 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.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsBindingConstants.java b/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsBindingConstants.java index fed6f775e8440..326c7a4c7d74a 100644 --- a/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsBindingConstants.java +++ b/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsBindingConstants.java @@ -13,6 +13,7 @@ package org.openhab.binding.bluetooth.airthings.internal; import java.math.BigInteger; +import java.util.Set; import javax.measure.Unit; import javax.measure.quantity.Dimensionless; @@ -34,6 +35,7 @@ * used across the whole binding. * * @author Pauli Anttila - Initial contribution + * @author Kai Kreuzer - Added Airthings Wave Mini support */ @NonNullByDefault public class AirthingsBindingConstants { @@ -41,6 +43,11 @@ public class AirthingsBindingConstants { // List of all Thing Type UIDs public static final ThingTypeUID THING_TYPE_AIRTHINGS_WAVE_PLUS = new ThingTypeUID( BluetoothBindingConstants.BINDING_ID, "airthings_wave_plus"); + public static final ThingTypeUID THING_TYPE_AIRTHINGS_WAVE_MINI = new ThingTypeUID( + BluetoothBindingConstants.BINDING_ID, "airthings_wave_mini"); + + public static final Set SUPPORTED_THING_TYPES_UIDS = Set.of(THING_TYPE_AIRTHINGS_WAVE_PLUS, + THING_TYPE_AIRTHINGS_WAVE_MINI); // Channel IDs public static final String CHANNEL_ID_HUMIDITY = "humidity"; diff --git a/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsDataParser.java b/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsDataParser.java new file mode 100644 index 0000000000000..80efba98cad18 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsDataParser.java @@ -0,0 +1,85 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.bluetooth.airthings.internal; + +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.HashMap; +import java.util.Map; + +import org.eclipse.jdt.annotation.NonNullByDefault; + +/** + * The {@link AirthingsDataParser} is responsible for parsing data from Wave Plus device format. + * + * @author Pauli Anttila - Initial contribution + * @author Kai Kreuzer - Added Airthings Wave Mini support + */ +@NonNullByDefault +public class AirthingsDataParser { + public static final String TVOC = "tvoc"; + public static final String CO2 = "co2"; + public static final String PRESSURE = "pressure"; + public static final String TEMPERATURE = "temperature"; + public static final String RADON_LONG_TERM_AVG = "radonLongTermAvg"; + public static final String RADON_SHORT_TERM_AVG = "radonShortTermAvg"; + public static final String HUMIDITY = "humidity"; + + private static final int EXPECTED_DATA_LEN = 20; + private static final int EXPECTED_VER_PLUS = 1; + + private AirthingsDataParser() { + } + + public static Map parseWavePlusData(int[] data) throws AirthingsParserException { + if (data.length == EXPECTED_DATA_LEN) { + final Map result = new HashMap<>(); + + final int version = data[0]; + + if (version == EXPECTED_VER_PLUS) { + result.put(HUMIDITY, data[1] / 2D); + result.put(RADON_SHORT_TERM_AVG, intFromBytes(data[4], data[5])); + result.put(RADON_LONG_TERM_AVG, intFromBytes(data[6], data[7])); + result.put(TEMPERATURE, intFromBytes(data[8], data[9]) / 100D); + result.put(PRESSURE, intFromBytes(data[10], data[11]) / 50D); + result.put(CO2, intFromBytes(data[12], data[13])); + result.put(TVOC, intFromBytes(data[14], data[15])); + return result; + } else { + throw new AirthingsParserException(String.format("Unsupported data structure version '%d'", version)); + } + } else { + throw new AirthingsParserException(String.format("Illegal data structure length '%d'", data.length)); + } + } + + public static Map parseWaveMiniData(int[] data) throws AirthingsParserException { + if (data.length == EXPECTED_DATA_LEN) { + final Map result = new HashMap<>(); + result.put(TEMPERATURE, + new BigDecimal(intFromBytes(data[2], data[3])) + .divide(BigDecimal.valueOf(100), 2, RoundingMode.HALF_UP) + .subtract(BigDecimal.valueOf(273.15)).doubleValue()); + result.put(HUMIDITY, intFromBytes(data[6], data[7]) / 100D); + result.put(TVOC, intFromBytes(data[8], data[9])); + return result; + } else { + throw new AirthingsParserException(String.format("Illegal data structure length '%d'", data.length)); + } + } + + private static int intFromBytes(int lowByte, int highByte) { + return (highByte & 0xFF) << 8 | (lowByte & 0xFF); + } +} diff --git a/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsDiscoveryParticipant.java b/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsDiscoveryParticipant.java index 58fd3f3da4371..91eb0ee9558c2 100644 --- a/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsDiscoveryParticipant.java +++ b/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsDiscoveryParticipant.java @@ -12,7 +12,6 @@ */ package org.openhab.binding.bluetooth.airthings.internal; -import java.util.Collections; import java.util.HashMap; import java.util.Map; import java.util.Set; @@ -33,6 +32,7 @@ * This discovery participant is able to recognize Airthings devices and create discovery results for them. * * @author Pauli Anttila - Initial contribution + * @author Kai Kreuzer - Added Airthings Wave Mini support * */ @NonNullByDefault @@ -42,10 +42,11 @@ public class AirthingsDiscoveryParticipant implements BluetoothDiscoveryParticip private static final int AIRTHINGS_COMPANY_ID = 820; // Formerly Corentium AS private static final String WAVE_PLUS_MODEL = "2930"; + private static final String WAVE_MINI_MODEL = "2920"; @Override public Set getSupportedThingTypeUIDs() { - return Collections.singleton(AirthingsBindingConstants.THING_TYPE_AIRTHINGS_WAVE_PLUS); + return AirthingsBindingConstants.SUPPORTED_THING_TYPES_UIDS; } @Override @@ -55,6 +56,10 @@ public Set getSupportedThingTypeUIDs() { return new ThingUID(AirthingsBindingConstants.THING_TYPE_AIRTHINGS_WAVE_PLUS, device.getAdapter().getUID(), device.getAddress().toString().toLowerCase().replace(":", "")); } + if (WAVE_MINI_MODEL.equals(device.getModel())) { + return new ThingUID(AirthingsBindingConstants.THING_TYPE_AIRTHINGS_WAVE_MINI, + device.getAdapter().getUID(), device.getAddress().toString().toLowerCase().replace(":", "")); + } } return null; } @@ -69,7 +74,10 @@ public Set getSupportedThingTypeUIDs() { return null; } if (WAVE_PLUS_MODEL.equals(device.getModel())) { - return createWavePlus(device, thingUID); + return createResult(device, thingUID, "Airthings Wave Plus"); + } + if (WAVE_MINI_MODEL.equals(device.getModel())) { + return createResult(device, thingUID, "Airthings Wave Mini"); } return null; } @@ -87,7 +95,7 @@ private boolean isAirthingsDevice(BluetoothDiscoveryDevice device) { return false; } - private DiscoveryResult createWavePlus(BluetoothDiscoveryDevice device, ThingUID thingUID) { + 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, "Airthings AS"); @@ -116,6 +124,6 @@ private DiscoveryResult createWavePlus(BluetoothDiscoveryDevice device, ThingUID // Create the discovery result and add to the inbox return DiscoveryResultBuilder.create(thingUID).withProperties(properties) .withRepresentationProperty(BluetoothBindingConstants.CONFIGURATION_ADDRESS) - .withBridge(device.getAdapter().getUID()).withLabel("Airthings Wave+").build(); + .withBridge(device.getAdapter().getUID()).withLabel(label).build(); } } diff --git a/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsHandlerFactory.java b/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsHandlerFactory.java index f2df72429dece..1b1c2b5b0c42b 100644 --- a/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsHandlerFactory.java +++ b/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsHandlerFactory.java @@ -12,9 +12,6 @@ */ package org.openhab.binding.bluetooth.airthings.internal; -import java.util.Collections; -import java.util.Set; - import org.eclipse.jdt.annotation.NonNullByDefault; import org.eclipse.jdt.annotation.Nullable; import org.openhab.core.thing.Thing; @@ -28,17 +25,15 @@ * The {@link AirthingsHandlerFactory} is responsible for creating things and thing handlers. * * @author Pauli Anttila - Initial contribution + * @author Kai Kreuzer - Added Airthings Wave Mini support */ @NonNullByDefault @Component(service = ThingHandlerFactory.class, configurationPid = "binding.airthings") public class AirthingsHandlerFactory extends BaseThingHandlerFactory { - private static final Set SUPPORTED_THING_TYPES_UIDS = Collections - .singleton(AirthingsBindingConstants.THING_TYPE_AIRTHINGS_WAVE_PLUS); - @Override public boolean supportsThingType(ThingTypeUID thingTypeUID) { - return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); + return AirthingsBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID); } @Override @@ -47,6 +42,9 @@ public boolean supportsThingType(ThingTypeUID thingTypeUID) { if (thingTypeUID.equals(AirthingsBindingConstants.THING_TYPE_AIRTHINGS_WAVE_PLUS)) { return new AirthingsWavePlusHandler(thing); } + if (thingTypeUID.equals(AirthingsBindingConstants.THING_TYPE_AIRTHINGS_WAVE_MINI)) { + return new AirthingsWaveMiniHandler(thing); + } return null; } } diff --git a/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsWaveMiniHandler.java b/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsWaveMiniHandler.java new file mode 100644 index 0000000000000..206e043141f9f --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsWaveMiniHandler.java @@ -0,0 +1,77 @@ +/** + * Copyright (c) 2010-2021 Contributors to the openHAB project + * + * See the NOTICE file(s) distributed with this work for additional + * information. + * + * This program and the accompanying materials are made available under the + * terms of the Eclipse Public License 2.0 which is available at + * http://www.eclipse.org/legal/epl-2.0 + * + * SPDX-License-Identifier: EPL-2.0 + */ +package org.openhab.binding.bluetooth.airthings.internal; + +import static org.openhab.binding.bluetooth.airthings.internal.AirthingsBindingConstants.*; + +import java.util.Map; +import java.util.UUID; + +import javax.measure.quantity.Dimensionless; +import javax.measure.quantity.Temperature; + +import org.eclipse.jdt.annotation.NonNullByDefault; +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.Thing; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +/** + * The {@link AirthingsWaveMiniHandler} is responsible for handling commands, which are + * sent to one of the channels. + * + * @author Kai Kreuzer - Initial contribution + */ +@NonNullByDefault +public class AirthingsWaveMiniHandler extends AbstractAirthingsHandler { + + private static final String DATA_UUID = "b42e3b98-ade7-11e4-89d3-123b93f75cba"; + + public AirthingsWaveMiniHandler(Thing thing) { + super(thing); + } + + private final Logger logger = LoggerFactory.getLogger(AirthingsWaveMiniHandler.class); + + private final UUID uuid = UUID.fromString(DATA_UUID); + + @Override + protected void updateChannels(int[] is) { + Map data; + try { + data = AirthingsDataParser.parseWaveMiniData(is); + logger.debug("Parsed data: {}", data); + Number humidity = data.get(AirthingsDataParser.HUMIDITY); + if (humidity != null) { + updateState(CHANNEL_ID_HUMIDITY, new QuantityType(humidity, Units.PERCENT)); + } + Number temperature = data.get(AirthingsDataParser.TEMPERATURE); + if (temperature != null) { + updateState(CHANNEL_ID_TEMPERATURE, new QuantityType(temperature, SIUnits.CELSIUS)); + } + Number tvoc = data.get(AirthingsDataParser.TVOC); + if (tvoc != null) { + updateState(CHANNEL_ID_TVOC, new QuantityType(tvoc, PARTS_PER_BILLION)); + } + } catch (AirthingsParserException e) { + logger.error("Failed to parse data received from Airthings sensor: {}", e.getMessage()); + } + } + + @Override + protected UUID getDataUUID() { + return uuid; + } +} diff --git a/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsWavePlusDataParser.java b/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsWavePlusDataParser.java deleted file mode 100644 index d2c4f8fab1d6c..0000000000000 --- a/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsWavePlusDataParser.java +++ /dev/null @@ -1,97 +0,0 @@ -/** - * 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.airthings.internal; - -import org.eclipse.jdt.annotation.NonNullByDefault; - -/** - * The {@link AirthingsWavePlusDataParser} is responsible for parsing data from Wave Plus device format. - * - * @author Pauli Anttila - Initial contribution - */ -@NonNullByDefault -public class AirthingsWavePlusDataParser { - private static final int EXPECTED_DATA_LEN = 20; - private static final int EXPECTED_VER = 1; - - private double humidity; - private int radonShortTermAvg; - private int radonLongTermAvg; - private double temperature; - private double pressure; - private int co2; - private int tvoc; - - public AirthingsWavePlusDataParser(int[] data) throws AirthingsParserException { - parseData(data); - } - - public double getHumidity() { - return humidity; - } - - public int getRadonShortTermAvg() { - return radonShortTermAvg; - } - - public int getRadonLongTermAvg() { - return radonLongTermAvg; - } - - public double getTemperature() { - return temperature; - } - - public double getPressure() { - return pressure; - } - - public int getCo2() { - return co2; - } - - public int getTvoc() { - return tvoc; - } - - private void parseData(int[] data) throws AirthingsParserException { - if (data.length == EXPECTED_DATA_LEN) { - final int version = data[0]; - - if (version == EXPECTED_VER) { - humidity = data[1] / 2D; - radonShortTermAvg = intFromBytes(data[4], data[5]); - radonLongTermAvg = intFromBytes(data[6], data[7]); - temperature = intFromBytes(data[8], data[9]) / 100D; - pressure = intFromBytes(data[10], data[11]) / 50D; - co2 = intFromBytes(data[12], data[13]); - tvoc = intFromBytes(data[14], data[15]); - } else { - throw new AirthingsParserException(String.format("Unsupported data structure version '%d'", version)); - } - } else { - throw new AirthingsParserException(String.format("Illegal data structure length '%d'", data.length)); - } - } - - private int intFromBytes(int lowByte, int highByte) { - return (highByte & 0xFF) << 8 | (lowByte & 0xFF); - } - - @Override - public String toString() { - return String.format( - "[humidity=%.1f %%rH, radonShortTermAvg=%d Bq/m3, radonLongTermAvg=%d Bq/m3, temperature=%.1f °C, air pressure=%.2f mbar, co2=%d ppm, tvoc=%d ppb]", - humidity, radonShortTermAvg, radonLongTermAvg, temperature, pressure, co2, tvoc); - } -} diff --git a/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsWavePlusHandler.java b/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsWavePlusHandler.java index 4cac3551856c4..6b24d50618250 100644 --- a/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsWavePlusHandler.java +++ b/bundles/org.openhab.binding.bluetooth.airthings/src/main/java/org/openhab/binding/bluetooth/airthings/internal/AirthingsWavePlusHandler.java @@ -14,25 +14,19 @@ import static org.openhab.binding.bluetooth.airthings.internal.AirthingsBindingConstants.*; -import java.util.Optional; +import java.util.Map; import java.util.UUID; -import java.util.concurrent.ScheduledFuture; -import java.util.concurrent.TimeUnit; -import java.util.concurrent.atomic.AtomicInteger; + +import javax.measure.quantity.Dimensionless; +import javax.measure.quantity.Pressure; +import javax.measure.quantity.Temperature; 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.library.dimension.Density; 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.Thing; -import org.openhab.core.thing.ThingStatus; -import org.openhab.core.thing.ThingStatusDetail; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -41,214 +35,63 @@ * sent to one of the channels. * * @author Pauli Anttila - Initial contribution + * @author Kai Kreuzer - Added Airthings Wave Mini support */ @NonNullByDefault -public class AirthingsWavePlusHandler extends BeaconBluetoothHandler { +public class AirthingsWavePlusHandler extends AbstractAirthingsHandler { private static final String DATA_UUID = "b42e2a68-ade7-11e4-89d3-123b93f75cba"; - private static final int CHECK_PERIOD_SEC = 10; - - private final Logger logger = LoggerFactory.getLogger(AirthingsWavePlusHandler.class); - private final UUID uuid = UUID.fromString(DATA_UUID); - - private AtomicInteger sinceLastReadSec = new AtomicInteger(); - private Optional configuration = Optional.empty(); - private @Nullable ScheduledFuture scheduledTask; - - private volatile int refreshInterval; - - 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, - } public AirthingsWavePlusHandler(Thing thing) { super(thing); } - @Override - public void initialize() { - logger.debug("Initialize"); - super.initialize(); - configuration = Optional.of(getConfigAs(AirthingsConfiguration.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 - } + private final Logger logger = LoggerFactory.getLogger(AirthingsWavePlusHandler.class); + private final UUID uuid = UUID.fromString(DATA_UUID); @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 DISCOVERED: - case DISCONNECTED: - if (isTimeToRead()) { - connect(); - } - break; - case CONNECTED: - read(); - break; - default: - break; + protected void updateChannels(int[] is) { + Map data; + try { + data = AirthingsDataParser.parseWavePlusData(is); + logger.debug("Parsed data: {}", data); + Number humidity = data.get(AirthingsDataParser.HUMIDITY); + if (humidity != null) { + updateState(CHANNEL_ID_HUMIDITY, new QuantityType(humidity, Units.PERCENT)); + } + Number temperature = data.get(AirthingsDataParser.TEMPERATURE); + if (temperature != null) { + updateState(CHANNEL_ID_TEMPERATURE, new QuantityType(temperature, SIUnits.CELSIUS)); + } + Number pressure = data.get(AirthingsDataParser.PRESSURE); + if (pressure != null) { + updateState(CHANNEL_ID_PRESSURE, new QuantityType(pressure, Units.MILLIBAR)); + } + Number co2 = data.get(AirthingsDataParser.CO2); + if (co2 != null) { + updateState(CHANNEL_ID_CO2, new QuantityType(co2, Units.PARTS_PER_MILLION)); + } + Number tvoc = data.get(AirthingsDataParser.TVOC); + if (tvoc != null) { + updateState(CHANNEL_ID_TVOC, new QuantityType(tvoc, PARTS_PER_BILLION)); + } + Number radonShortTermAvg = data.get(AirthingsDataParser.RADON_SHORT_TERM_AVG); + if (radonShortTermAvg != null) { + updateState(CHANNEL_ID_RADON_ST_AVG, + new QuantityType(radonShortTermAvg, BECQUEREL_PER_CUBIC_METRE)); + } + Number radonLongTermAvg = data.get(AirthingsDataParser.RADON_LONG_TERM_AVG); + if (radonLongTermAvg != null) { + updateState(CHANNEL_ID_RADON_LT_AVG, + new QuantityType(radonLongTermAvg, BECQUEREL_PER_CUBIC_METRE)); + } + } catch (AirthingsParserException e) { + logger.error("Failed to parse data received from Airthings sensor: {}", e.getMessage()); } } - private void connect() { - logger.debug("Connect to device {}...", address); - if (!device.connect()) { - logger.debug("Connecting to device {} failed", address); - } - } - - private void disconnect() { - logger.debug("Disconnect from device {}...", address); - if (!device.disconnect()) { - logger.debug("Disconnect from device {} failed", address); - } - } - - private void read() { - switch (serviceState) { - case NOT_RESOLVED: - discoverServices(); - break; - case RESOLVED: - switch (readState) { - case IDLE: - logger.debug("Read data from device {}...", address); - BluetoothCharacteristic characteristic = device.getCharacteristic(uuid); - - if (characteristic != null) { - readState = ReadState.READING; - device.readCharacteristic(characteristic).whenComplete((data, ex) -> { - try { - if (data != null) { - logger.debug("Characteristic {} from device {}: {}", characteristic.getUuid(), - address, data); - updateStatus(ThingStatus.ONLINE); - sinceLastReadSec.set(0); - try { - updateChannels( - new AirthingsWavePlusDataParser(BluetoothUtils.toIntArray(data))); - } catch (AirthingsParserException e) { - logger.warn( - "Data parsing error occured, when parsing data from device {}, cause {}", - address, e.getMessage(), e); - } - } else { - logger.debug("Characteristic {} from device {} failed: {}", - characteristic.getUuid(), address, ex.getMessage()); - updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, - ex.getMessage()); - } - } finally { - readState = ReadState.IDLE; - disconnect(); - } - }); - } else { - logger.debug("Read data from device {} failed", address); - disconnect(); - } - break; - default: - break; - } - default: - break; - } - } - - 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) { - switch (connectionNotification.getConnectionState()) { - case DISCONNECTED: - if (serviceState == ServiceState.RESOLVING) { - serviceState = ServiceState.NOT_RESOLVED; - } - readState = ReadState.IDLE; - break; - default: - break; - - } - execute(); - } - - private void updateChannels(AirthingsWavePlusDataParser parser) { - logger.debug("Parsed data: {}", parser); - updateState(CHANNEL_ID_HUMIDITY, QuantityType.valueOf(Double.valueOf(parser.getHumidity()), Units.PERCENT)); - updateState(CHANNEL_ID_TEMPERATURE, - QuantityType.valueOf(Double.valueOf(parser.getTemperature()), SIUnits.CELSIUS)); - updateState(CHANNEL_ID_PRESSURE, QuantityType.valueOf(Double.valueOf(parser.getPressure()), Units.MILLIBAR)); - updateState(CHANNEL_ID_CO2, QuantityType.valueOf(Double.valueOf(parser.getCo2()), Units.PARTS_PER_MILLION)); - updateState(CHANNEL_ID_TVOC, QuantityType.valueOf(Double.valueOf(parser.getTvoc()), PARTS_PER_BILLION)); - updateState(CHANNEL_ID_RADON_ST_AVG, - QuantityType.valueOf(Double.valueOf(parser.getRadonShortTermAvg()), BECQUEREL_PER_CUBIC_METRE)); - updateState(CHANNEL_ID_RADON_LT_AVG, - QuantityType.valueOf(Double.valueOf(parser.getRadonLongTermAvg()), BECQUEREL_PER_CUBIC_METRE)); - } - - private boolean isTimeToRead() { - int sinceLastRead = sinceLastReadSec.get(); - logger.debug("Time since last update: {} sec", sinceLastRead); - return sinceLastRead >= refreshInterval; + protected UUID getDataUUID() { + return uuid; } } diff --git a/bundles/org.openhab.binding.bluetooth.airthings/src/main/resources/OH-INF/thing/airthings.xml b/bundles/org.openhab.binding.bluetooth.airthings/src/main/resources/OH-INF/thing/airthings.xml index 0e3942057ff81..3f7419ac5f0d4 100644 --- a/bundles/org.openhab.binding.bluetooth.airthings/src/main/resources/OH-INF/thing/airthings.xml +++ b/bundles/org.openhab.binding.bluetooth.airthings/src/main/resources/OH-INF/thing/airthings.xml @@ -4,6 +4,37 @@ 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"> + + + + + + + + + Indoor air quality 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 + + + + @@ -11,7 +42,7 @@ - + Indoor air quality monitor with radon detection @@ -43,7 +74,7 @@ Number:Dimensionless Humidity level - + Number:Temperature diff --git a/bundles/org.openhab.binding.bluetooth.airthings/src/test/java/org/openhab/binding/bluetooth/airthings/AirthingsParserTest.java b/bundles/org.openhab.binding.bluetooth.airthings/src/test/java/org/openhab/binding/bluetooth/airthings/AirthingsParserTest.java new file mode 100644 index 0000000000000..d4662b6e74bd6 --- /dev/null +++ b/bundles/org.openhab.binding.bluetooth.airthings/src/test/java/org/openhab/binding/bluetooth/airthings/AirthingsParserTest.java @@ -0,0 +1,73 @@ +/** + * 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.airthings; + +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.airthings.internal.AirthingsDataParser; +import org.openhab.binding.bluetooth.airthings.internal.AirthingsParserException; + +/** + * Tests {@link AirthingsParserTest}. + * + * @author Pauli Anttila - Initial contribution + */ +@NonNullByDefault +public class AirthingsParserTest { + + @Test + public void testWrongVersion() { + int[] data = { 5, 55, 51, 0, 122, 0, 61, 0, 119, 9, 11, 194, 169, 2, 46, 0, 0, 0, 4, 20 }; + assertThrows(AirthingsParserException.class, () -> AirthingsDataParser.parseWavePlusData(data)); + } + + @Test + public void testEmptyData() { + int[] data = {}; + assertThrows(AirthingsParserException.class, () -> AirthingsDataParser.parseWavePlusData(data)); + } + + @Test + public void testWrongDataLen() throws AirthingsParserException { + int[] data = { 1, 55, 51, 0, 122, 0, 61, 0, 119, 9, 11, 194, 169, 2, 46, 0, 0 }; + assertThrows(AirthingsParserException.class, () -> AirthingsDataParser.parseWavePlusData(data)); + } + + @Test + public void testParsingPlus() throws AirthingsParserException { + int[] data = { 1, 55, 51, 0, 122, 0, 61, 0, 119, 9, 11, 194, 169, 2, 46, 0, 0, 0, 4, 20 }; + Map result = AirthingsDataParser.parseWavePlusData(data); + + assertEquals(27.5, result.get(AirthingsDataParser.HUMIDITY)); + assertEquals(681, result.get(AirthingsDataParser.CO2)); + assertEquals(46, result.get(AirthingsDataParser.TVOC)); + assertEquals(24.23, result.get(AirthingsDataParser.TEMPERATURE)); + assertEquals(993.5, result.get(AirthingsDataParser.PRESSURE)); + assertEquals(61, result.get(AirthingsDataParser.RADON_LONG_TERM_AVG)); + assertEquals(122, result.get(AirthingsDataParser.RADON_SHORT_TERM_AVG)); + } + + @Test + public void testParsingMini() throws AirthingsParserException { + int[] data = { 12, 0, 248, 112, 201, 193, 136, 14, 150, 0, 1, 0, 217, 176, 14, 0, 255, 255, 255, 255 }; + Map result = AirthingsDataParser.parseWaveMiniData(data); + + assertEquals(37.2, result.get(AirthingsDataParser.HUMIDITY)); + assertEquals(150, result.get(AirthingsDataParser.TVOC)); + assertEquals(16.05, result.get(AirthingsDataParser.TEMPERATURE)); + } +} diff --git a/bundles/org.openhab.binding.bluetooth.airthings/src/test/java/org/openhab/binding/bluetooth/airthings/AirthingsWavePlusParserTest.java b/bundles/org.openhab.binding.bluetooth.airthings/src/test/java/org/openhab/binding/bluetooth/airthings/AirthingsWavePlusParserTest.java deleted file mode 100644 index ba67de34da148..0000000000000 --- a/bundles/org.openhab.binding.bluetooth.airthings/src/test/java/org/openhab/binding/bluetooth/airthings/AirthingsWavePlusParserTest.java +++ /dev/null @@ -1,61 +0,0 @@ -/** - * 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.airthings; - -import static org.junit.jupiter.api.Assertions.*; - -import org.eclipse.jdt.annotation.NonNullByDefault; -import org.junit.jupiter.api.Test; -import org.openhab.binding.bluetooth.airthings.internal.AirthingsParserException; -import org.openhab.binding.bluetooth.airthings.internal.AirthingsWavePlusDataParser; - -/** - * Tests {@link AirthingsWavePlusParserTest}. - * - * @author Pauli Anttila - Initial contribution - */ -@NonNullByDefault -public class AirthingsWavePlusParserTest { - - @Test - public void testWrongVersion() { - int[] data = { 5, 55, 51, 0, 122, 0, 61, 0, 119, 9, 11, 194, 169, 2, 46, 0, 0, 0, 4, 20 }; - assertThrows(AirthingsParserException.class, () -> new AirthingsWavePlusDataParser(data)); - } - - @Test - public void testEmptyData() { - int[] data = {}; - assertThrows(AirthingsParserException.class, () -> new AirthingsWavePlusDataParser(data)); - } - - @Test - public void testWrongDataLen() throws AirthingsParserException { - int[] data = { 1, 55, 51, 0, 122, 0, 61, 0, 119, 9, 11, 194, 169, 2, 46, 0, 0 }; - assertThrows(AirthingsParserException.class, () -> new AirthingsWavePlusDataParser(data)); - } - - @Test - public void testParsing() throws AirthingsParserException { - int[] data = { 1, 55, 51, 0, 122, 0, 61, 0, 119, 9, 11, 194, 169, 2, 46, 0, 0, 0, 4, 20 }; - AirthingsWavePlusDataParser parser = new AirthingsWavePlusDataParser(data); - - assertEquals(27.5, parser.getHumidity(), 0.01); - assertEquals(681, parser.getCo2()); - assertEquals(46, parser.getTvoc()); - assertEquals(24.23, parser.getTemperature(), 0.01); - assertEquals(993.5, parser.getPressure(), 0.01); - assertEquals(61, parser.getRadonLongTermAvg()); - assertEquals(122, parser.getRadonShortTermAvg()); - } -}