| Thing Type ID | Description |
| ------------------- | ------------------------- |
-| airthings_wave_plus | Airthings Wave+ |
+| airthings_wave_plus | Airthings Wave Plus |
+| airthings_wave_mini | Airthings Wave Mini |
## Discovery
## Thing Configuration
-Supported configuration parameters for `Airthings Wave+` thing:
+Supported configuration parameters for the things:
| Property | Type | Default | Required | Description |
|---------------------------------|---------|---------|----------|-----------------------------------------------------------------|
## 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`:
--- /dev/null
+/**
+ * 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<AirthingsConfiguration> 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);
+}
package org.openhab.binding.bluetooth.airthings.internal;
import java.math.BigInteger;
+import java.util.Set;
import javax.measure.Unit;
import javax.measure.quantity.Dimensionless;
* used across the whole binding.
*
* @author Pauli Anttila - Initial contribution
+ * @author Kai Kreuzer - Added Airthings Wave Mini support
*/
@NonNullByDefault
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<ThingTypeUID> 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";
--- /dev/null
+/**
+ * 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<String, Number> parseWavePlusData(int[] data) throws AirthingsParserException {
+ if (data.length == EXPECTED_DATA_LEN) {
+ final Map<String, Number> 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<String, Number> parseWaveMiniData(int[] data) throws AirthingsParserException {
+ if (data.length == EXPECTED_DATA_LEN) {
+ final Map<String, Number> 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);
+ }
+}
*/
package org.openhab.binding.bluetooth.airthings.internal;
-import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.Set;
* 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
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<ThingTypeUID> getSupportedThingTypeUIDs() {
- return Collections.singleton(AirthingsBindingConstants.THING_TYPE_AIRTHINGS_WAVE_PLUS);
+ return AirthingsBindingConstants.SUPPORTED_THING_TYPES_UIDS;
}
@Override
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;
}
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;
}
return false;
}
- private DiscoveryResult createWavePlus(BluetoothDiscoveryDevice device, ThingUID thingUID) {
+ 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, "Airthings AS");
// 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();
}
}
*/
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;
* 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<ThingTypeUID> 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
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;
}
}
--- /dev/null
+/**
+ * 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<String, Number> 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<Dimensionless>(humidity, Units.PERCENT));
+ }
+ Number temperature = data.get(AirthingsDataParser.TEMPERATURE);
+ if (temperature != null) {
+ updateState(CHANNEL_ID_TEMPERATURE, new QuantityType<Temperature>(temperature, SIUnits.CELSIUS));
+ }
+ Number tvoc = data.get(AirthingsDataParser.TVOC);
+ if (tvoc != null) {
+ updateState(CHANNEL_ID_TVOC, new QuantityType<Dimensionless>(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;
+ }
+}
+++ /dev/null
-/**
- * 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);
- }
-}
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;
* 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<AirthingsConfiguration> 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<String, Number> 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<Dimensionless>(humidity, Units.PERCENT));
+ }
+ Number temperature = data.get(AirthingsDataParser.TEMPERATURE);
+ if (temperature != null) {
+ updateState(CHANNEL_ID_TEMPERATURE, new QuantityType<Temperature>(temperature, SIUnits.CELSIUS));
+ }
+ Number pressure = data.get(AirthingsDataParser.PRESSURE);
+ if (pressure != null) {
+ updateState(CHANNEL_ID_PRESSURE, new QuantityType<Pressure>(pressure, Units.MILLIBAR));
+ }
+ Number co2 = data.get(AirthingsDataParser.CO2);
+ if (co2 != null) {
+ updateState(CHANNEL_ID_CO2, new QuantityType<Dimensionless>(co2, Units.PARTS_PER_MILLION));
+ }
+ Number tvoc = data.get(AirthingsDataParser.TVOC);
+ if (tvoc != null) {
+ updateState(CHANNEL_ID_TVOC, new QuantityType<Dimensionless>(tvoc, PARTS_PER_BILLION));
+ }
+ Number radonShortTermAvg = data.get(AirthingsDataParser.RADON_SHORT_TERM_AVG);
+ if (radonShortTermAvg != null) {
+ updateState(CHANNEL_ID_RADON_ST_AVG,
+ new QuantityType<Density>(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<Density>(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;
}
}
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="airthings_wave_mini">
+ <supported-bridge-type-refs>
+ <bridge-type-ref id="roaming"/>
+ <bridge-type-ref id="bluegiga"/>
+ <bridge-type-ref id="bluez"/>
+ </supported-bridge-type-refs>
+
+ <label>Airthings Wave Mini</label>
+ <description>Indoor air quality monitor</description>
+
+ <channels>
+ <channel id="rssi" typeId="rssi"/>
+
+ <channel id="humidity" typeId="airthings_humidity"/>
+ <channel id="temperature" typeId="airthings_temperature"/>
+ <channel id="tvoc" typeId="airthings_tvoc"/>
+ </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>
+
<thing-type id="airthings_wave_plus">
<supported-bridge-type-refs>
<bridge-type-ref id="roaming"/>
<bridge-type-ref id="bluez"/>
</supported-bridge-type-refs>
- <label>Airthings Wave+</label>
+ <label>Airthings Wave Plus</label>
<description>Indoor air quality monitor with radon detection</description>
<channels>
<item-type>Number:Dimensionless</item-type>
<label>Humidity</label>
<description>Humidity level</description>
- <state readOnly="true" pattern="%.1f %unit%"/>
+ <state readOnly="true" pattern="%.1f %%"/>
</channel-type>
<channel-type id="airthings_temperature">
<item-type>Number:Temperature</item-type>
--- /dev/null
+/**
+ * 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<String, Number> 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<String, Number> result = AirthingsDataParser.parseWaveMiniData(data);
+
+ assertEquals(37.2, result.get(AirthingsDataParser.HUMIDITY));
+ assertEquals(150, result.get(AirthingsDataParser.TVOC));
+ assertEquals(16.05, result.get(AirthingsDataParser.TEMPERATURE));
+ }
+}
+++ /dev/null
-/**
- * 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());
- }
-}