Skip to content

Commit

Permalink
[airthings] Add support for Airthings Wave Mini (#10456)
Browse files Browse the repository at this point in the history
Signed-off-by: Kai Kreuzer <[email protected]>
  • Loading branch information
kaikreuzer authored Apr 16, 2021
1 parent 1633c70 commit 2e42126
Show file tree
Hide file tree
Showing 12 changed files with 586 additions and 382 deletions.
15 changes: 11 additions & 4 deletions bundles/org.openhab.binding.bluetooth.airthings/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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 |
|---------------------------------|---------|---------|----------|-----------------------------------------------------------------|
Expand All @@ -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`:
Expand Down
Original file line number Diff line number Diff line change
@@ -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<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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,13 +35,19 @@
* 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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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<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);
}
}
Loading

0 comments on commit 2e42126

Please sign in to comment.