[airthings] Add support for Airthings Wave Mini (#10456)
Signed-off-by: Kai Kreuzer <kai@openhab.org>
This commit is contained in:
parent
1633c705a1
commit
2e421267cf
|
@ -8,7 +8,8 @@ Following thing types are supported by this extension:
|
||||||
|
|
||||||
| Thing Type ID | Description |
|
| Thing Type ID | Description |
|
||||||
| ------------------- | ------------------------- |
|
| ------------------- | ------------------------- |
|
||||||
| airthings_wave_plus | Airthings Wave+ |
|
| airthings_wave_plus | Airthings Wave Plus |
|
||||||
|
| airthings_wave_mini | Airthings Wave Mini |
|
||||||
|
|
||||||
|
|
||||||
## Discovery
|
## Discovery
|
||||||
|
@ -17,7 +18,7 @@ As any other Bluetooth device, Airthings devices are discovered automatically by
|
||||||
|
|
||||||
## Thing Configuration
|
## Thing Configuration
|
||||||
|
|
||||||
Supported configuration parameters for `Airthings Wave+` thing:
|
Supported configuration parameters for the things:
|
||||||
|
|
||||||
| Property | Type | Default | Required | Description |
|
| Property | Type | Default | Required | Description |
|
||||||
|---------------------------------|---------|---------|----------|-----------------------------------------------------------------|
|
|---------------------------------|---------|---------|----------|-----------------------------------------------------------------|
|
||||||
|
@ -26,18 +27,24 @@ Supported configuration parameters for `Airthings Wave+` thing:
|
||||||
|
|
||||||
## Channels
|
## Channels
|
||||||
|
|
||||||
Following channels are supported for `Airthings Wave+` thing:
|
Following channels are supported for `Airthings Wave Mini` thing:
|
||||||
|
|
||||||
| Channel ID | Item Type | Description |
|
| Channel ID | Item Type | Description |
|
||||||
| ------------------ | ------------------------ | ------------------------------------------- |
|
| ------------------ | ------------------------ | ------------------------------------------- |
|
||||||
| temperature | Number:Temperature | The measured temperature |
|
| temperature | Number:Temperature | The measured temperature |
|
||||||
| humidity | Number:Dimensionless | The measured humidity |
|
| 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 |
|
| pressure | Number:Pressure | The measured air pressure |
|
||||||
| co2 | Number:Dimensionless | The measured CO2 level |
|
| 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_st_avg | Number:Density | The measured radon short term average level |
|
||||||
| radon_lt_avg | Number:Density | The measured radon long term average level |
|
| radon_lt_avg | Number:Density | The measured radon long term average level |
|
||||||
|
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
airthings.things (assuming you have a Bluetooth bridge with the ID `bluetooth:bluegiga:adapter1`:
|
airthings.things (assuming you have a Bluetooth bridge with the ID `bluetooth:bluegiga:adapter1`:
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
|
@ -13,6 +13,7 @@
|
||||||
package org.openhab.binding.bluetooth.airthings.internal;
|
package org.openhab.binding.bluetooth.airthings.internal;
|
||||||
|
|
||||||
import java.math.BigInteger;
|
import java.math.BigInteger;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
import javax.measure.Unit;
|
import javax.measure.Unit;
|
||||||
import javax.measure.quantity.Dimensionless;
|
import javax.measure.quantity.Dimensionless;
|
||||||
|
@ -34,6 +35,7 @@ import tec.uom.se.unit.TransformedUnit;
|
||||||
* used across the whole binding.
|
* used across the whole binding.
|
||||||
*
|
*
|
||||||
* @author Pauli Anttila - Initial contribution
|
* @author Pauli Anttila - Initial contribution
|
||||||
|
* @author Kai Kreuzer - Added Airthings Wave Mini support
|
||||||
*/
|
*/
|
||||||
@NonNullByDefault
|
@NonNullByDefault
|
||||||
public class AirthingsBindingConstants {
|
public class AirthingsBindingConstants {
|
||||||
|
@ -41,6 +43,11 @@ public class AirthingsBindingConstants {
|
||||||
// List of all Thing Type UIDs
|
// List of all Thing Type UIDs
|
||||||
public static final ThingTypeUID THING_TYPE_AIRTHINGS_WAVE_PLUS = new ThingTypeUID(
|
public static final ThingTypeUID THING_TYPE_AIRTHINGS_WAVE_PLUS = new ThingTypeUID(
|
||||||
BluetoothBindingConstants.BINDING_ID, "airthings_wave_plus");
|
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
|
// Channel IDs
|
||||||
public static final String CHANNEL_ID_HUMIDITY = "humidity";
|
public static final String CHANNEL_ID_HUMIDITY = "humidity";
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -12,7 +12,6 @@
|
||||||
*/
|
*/
|
||||||
package org.openhab.binding.bluetooth.airthings.internal;
|
package org.openhab.binding.bluetooth.airthings.internal;
|
||||||
|
|
||||||
import java.util.Collections;
|
|
||||||
import java.util.HashMap;
|
import java.util.HashMap;
|
||||||
import java.util.Map;
|
import java.util.Map;
|
||||||
import java.util.Set;
|
import java.util.Set;
|
||||||
|
@ -33,6 +32,7 @@ import org.osgi.service.component.annotations.Component;
|
||||||
* This discovery participant is able to recognize Airthings devices and create discovery results for them.
|
* This discovery participant is able to recognize Airthings devices and create discovery results for them.
|
||||||
*
|
*
|
||||||
* @author Pauli Anttila - Initial contribution
|
* @author Pauli Anttila - Initial contribution
|
||||||
|
* @author Kai Kreuzer - Added Airthings Wave Mini support
|
||||||
*
|
*
|
||||||
*/
|
*/
|
||||||
@NonNullByDefault
|
@NonNullByDefault
|
||||||
|
@ -42,10 +42,11 @@ public class AirthingsDiscoveryParticipant implements BluetoothDiscoveryParticip
|
||||||
private static final int AIRTHINGS_COMPANY_ID = 820; // Formerly Corentium AS
|
private static final int AIRTHINGS_COMPANY_ID = 820; // Formerly Corentium AS
|
||||||
|
|
||||||
private static final String WAVE_PLUS_MODEL = "2930";
|
private static final String WAVE_PLUS_MODEL = "2930";
|
||||||
|
private static final String WAVE_MINI_MODEL = "2920";
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
|
public Set<ThingTypeUID> getSupportedThingTypeUIDs() {
|
||||||
return Collections.singleton(AirthingsBindingConstants.THING_TYPE_AIRTHINGS_WAVE_PLUS);
|
return AirthingsBindingConstants.SUPPORTED_THING_TYPES_UIDS;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -55,6 +56,10 @@ public class AirthingsDiscoveryParticipant implements BluetoothDiscoveryParticip
|
||||||
return new ThingUID(AirthingsBindingConstants.THING_TYPE_AIRTHINGS_WAVE_PLUS,
|
return new ThingUID(AirthingsBindingConstants.THING_TYPE_AIRTHINGS_WAVE_PLUS,
|
||||||
device.getAdapter().getUID(), device.getAddress().toString().toLowerCase().replace(":", ""));
|
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;
|
||||||
}
|
}
|
||||||
|
@ -69,7 +74,10 @@ public class AirthingsDiscoveryParticipant implements BluetoothDiscoveryParticip
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
if (WAVE_PLUS_MODEL.equals(device.getModel())) {
|
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 null;
|
||||||
}
|
}
|
||||||
|
@ -87,7 +95,7 @@ public class AirthingsDiscoveryParticipant implements BluetoothDiscoveryParticip
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
private DiscoveryResult createWavePlus(BluetoothDiscoveryDevice device, ThingUID thingUID) {
|
private DiscoveryResult createResult(BluetoothDiscoveryDevice device, ThingUID thingUID, String label) {
|
||||||
Map<String, Object> properties = new HashMap<>();
|
Map<String, Object> properties = new HashMap<>();
|
||||||
properties.put(BluetoothBindingConstants.CONFIGURATION_ADDRESS, device.getAddress().toString());
|
properties.put(BluetoothBindingConstants.CONFIGURATION_ADDRESS, device.getAddress().toString());
|
||||||
properties.put(Thing.PROPERTY_VENDOR, "Airthings AS");
|
properties.put(Thing.PROPERTY_VENDOR, "Airthings AS");
|
||||||
|
@ -116,6 +124,6 @@ public class AirthingsDiscoveryParticipant implements BluetoothDiscoveryParticip
|
||||||
// Create the discovery result and add to the inbox
|
// Create the discovery result and add to the inbox
|
||||||
return DiscoveryResultBuilder.create(thingUID).withProperties(properties)
|
return DiscoveryResultBuilder.create(thingUID).withProperties(properties)
|
||||||
.withRepresentationProperty(BluetoothBindingConstants.CONFIGURATION_ADDRESS)
|
.withRepresentationProperty(BluetoothBindingConstants.CONFIGURATION_ADDRESS)
|
||||||
.withBridge(device.getAdapter().getUID()).withLabel("Airthings Wave+").build();
|
.withBridge(device.getAdapter().getUID()).withLabel(label).build();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -12,9 +12,6 @@
|
||||||
*/
|
*/
|
||||||
package org.openhab.binding.bluetooth.airthings.internal;
|
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.NonNullByDefault;
|
||||||
import org.eclipse.jdt.annotation.Nullable;
|
import org.eclipse.jdt.annotation.Nullable;
|
||||||
import org.openhab.core.thing.Thing;
|
import org.openhab.core.thing.Thing;
|
||||||
|
@ -28,17 +25,15 @@ import org.osgi.service.component.annotations.Component;
|
||||||
* The {@link AirthingsHandlerFactory} is responsible for creating things and thing handlers.
|
* The {@link AirthingsHandlerFactory} is responsible for creating things and thing handlers.
|
||||||
*
|
*
|
||||||
* @author Pauli Anttila - Initial contribution
|
* @author Pauli Anttila - Initial contribution
|
||||||
|
* @author Kai Kreuzer - Added Airthings Wave Mini support
|
||||||
*/
|
*/
|
||||||
@NonNullByDefault
|
@NonNullByDefault
|
||||||
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.airthings")
|
@Component(service = ThingHandlerFactory.class, configurationPid = "binding.airthings")
|
||||||
public class AirthingsHandlerFactory extends BaseThingHandlerFactory {
|
public class AirthingsHandlerFactory extends BaseThingHandlerFactory {
|
||||||
|
|
||||||
private static final Set<ThingTypeUID> SUPPORTED_THING_TYPES_UIDS = Collections
|
|
||||||
.singleton(AirthingsBindingConstants.THING_TYPE_AIRTHINGS_WAVE_PLUS);
|
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
|
public boolean supportsThingType(ThingTypeUID thingTypeUID) {
|
||||||
return SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
|
return AirthingsBindingConstants.SUPPORTED_THING_TYPES_UIDS.contains(thingTypeUID);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
@ -47,6 +42,9 @@ public class AirthingsHandlerFactory extends BaseThingHandlerFactory {
|
||||||
if (thingTypeUID.equals(AirthingsBindingConstants.THING_TYPE_AIRTHINGS_WAVE_PLUS)) {
|
if (thingTypeUID.equals(AirthingsBindingConstants.THING_TYPE_AIRTHINGS_WAVE_PLUS)) {
|
||||||
return new AirthingsWavePlusHandler(thing);
|
return new AirthingsWavePlusHandler(thing);
|
||||||
}
|
}
|
||||||
|
if (thingTypeUID.equals(AirthingsBindingConstants.THING_TYPE_AIRTHINGS_WAVE_MINI)) {
|
||||||
|
return new AirthingsWaveMiniHandler(thing);
|
||||||
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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<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;
|
||||||
|
}
|
||||||
|
}
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -14,25 +14,19 @@ package org.openhab.binding.bluetooth.airthings.internal;
|
||||||
|
|
||||||
import static org.openhab.binding.bluetooth.airthings.internal.AirthingsBindingConstants.*;
|
import static org.openhab.binding.bluetooth.airthings.internal.AirthingsBindingConstants.*;
|
||||||
|
|
||||||
import java.util.Optional;
|
import java.util.Map;
|
||||||
import java.util.UUID;
|
import java.util.UUID;
|
||||||
import java.util.concurrent.ScheduledFuture;
|
|
||||||
import java.util.concurrent.TimeUnit;
|
import javax.measure.quantity.Dimensionless;
|
||||||
import java.util.concurrent.atomic.AtomicInteger;
|
import javax.measure.quantity.Pressure;
|
||||||
|
import javax.measure.quantity.Temperature;
|
||||||
|
|
||||||
import org.eclipse.jdt.annotation.NonNullByDefault;
|
import org.eclipse.jdt.annotation.NonNullByDefault;
|
||||||
import org.eclipse.jdt.annotation.Nullable;
|
import org.openhab.core.library.dimension.Density;
|
||||||
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.types.QuantityType;
|
import org.openhab.core.library.types.QuantityType;
|
||||||
import org.openhab.core.library.unit.SIUnits;
|
import org.openhab.core.library.unit.SIUnits;
|
||||||
import org.openhab.core.library.unit.Units;
|
import org.openhab.core.library.unit.Units;
|
||||||
import org.openhab.core.thing.Thing;
|
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.Logger;
|
||||||
import org.slf4j.LoggerFactory;
|
import org.slf4j.LoggerFactory;
|
||||||
|
|
||||||
|
@ -41,214 +35,63 @@ import org.slf4j.LoggerFactory;
|
||||||
* sent to one of the channels.
|
* sent to one of the channels.
|
||||||
*
|
*
|
||||||
* @author Pauli Anttila - Initial contribution
|
* @author Pauli Anttila - Initial contribution
|
||||||
|
* @author Kai Kreuzer - Added Airthings Wave Mini support
|
||||||
*/
|
*/
|
||||||
@NonNullByDefault
|
@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 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) {
|
public AirthingsWavePlusHandler(Thing thing) {
|
||||||
super(thing);
|
super(thing);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
private final Logger logger = LoggerFactory.getLogger(AirthingsWavePlusHandler.class);
|
||||||
public void initialize() {
|
private final UUID uuid = UUID.fromString(DATA_UUID);
|
||||||
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
|
@Override
|
||||||
public void dispose() {
|
protected void updateChannels(int[] is) {
|
||||||
logger.debug("Dispose");
|
Map<String, Number> data;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
if (data != null) {
|
data = AirthingsDataParser.parseWavePlusData(is);
|
||||||
logger.debug("Characteristic {} from device {}: {}", characteristic.getUuid(),
|
logger.debug("Parsed data: {}", data);
|
||||||
address, data);
|
Number humidity = data.get(AirthingsDataParser.HUMIDITY);
|
||||||
updateStatus(ThingStatus.ONLINE);
|
if (humidity != null) {
|
||||||
sinceLastReadSec.set(0);
|
updateState(CHANNEL_ID_HUMIDITY, new QuantityType<Dimensionless>(humidity, Units.PERCENT));
|
||||||
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 {
|
Number temperature = data.get(AirthingsDataParser.TEMPERATURE);
|
||||||
logger.debug("Characteristic {} from device {} failed: {}",
|
if (temperature != null) {
|
||||||
characteristic.getUuid(), address, ex.getMessage());
|
updateState(CHANNEL_ID_TEMPERATURE, new QuantityType<Temperature>(temperature, SIUnits.CELSIUS));
|
||||||
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
|
|
||||||
ex.getMessage());
|
|
||||||
}
|
}
|
||||||
} finally {
|
Number pressure = data.get(AirthingsDataParser.PRESSURE);
|
||||||
readState = ReadState.IDLE;
|
if (pressure != null) {
|
||||||
disconnect();
|
updateState(CHANNEL_ID_PRESSURE, new QuantityType<Pressure>(pressure, Units.MILLIBAR));
|
||||||
}
|
}
|
||||||
});
|
Number co2 = data.get(AirthingsDataParser.CO2);
|
||||||
} else {
|
if (co2 != null) {
|
||||||
logger.debug("Read data from device {} failed", address);
|
updateState(CHANNEL_ID_CO2, new QuantityType<Dimensionless>(co2, Units.PARTS_PER_MILLION));
|
||||||
disconnect();
|
|
||||||
}
|
}
|
||||||
break;
|
Number tvoc = data.get(AirthingsDataParser.TVOC);
|
||||||
default:
|
if (tvoc != null) {
|
||||||
break;
|
updateState(CHANNEL_ID_TVOC, new QuantityType<Dimensionless>(tvoc, PARTS_PER_BILLION));
|
||||||
}
|
}
|
||||||
default:
|
Number radonShortTermAvg = data.get(AirthingsDataParser.RADON_SHORT_TERM_AVG);
|
||||||
break;
|
if (radonShortTermAvg != null) {
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
updateState(CHANNEL_ID_RADON_ST_AVG,
|
||||||
QuantityType.valueOf(Double.valueOf(parser.getRadonShortTermAvg()), BECQUEREL_PER_CUBIC_METRE));
|
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,
|
updateState(CHANNEL_ID_RADON_LT_AVG,
|
||||||
QuantityType.valueOf(Double.valueOf(parser.getRadonLongTermAvg()), BECQUEREL_PER_CUBIC_METRE));
|
new QuantityType<Density>(radonLongTermAvg, BECQUEREL_PER_CUBIC_METRE));
|
||||||
|
}
|
||||||
|
} catch (AirthingsParserException e) {
|
||||||
|
logger.error("Failed to parse data received from Airthings sensor: {}", e.getMessage());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private boolean isTimeToRead() {
|
@Override
|
||||||
int sinceLastRead = sinceLastReadSec.get();
|
protected UUID getDataUUID() {
|
||||||
logger.debug("Time since last update: {} sec", sinceLastRead);
|
return uuid;
|
||||||
return sinceLastRead >= refreshInterval;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -4,6 +4,37 @@
|
||||||
xmlns:thing="https://openhab.org/schemas/thing-description/v1.0.0"
|
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">
|
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">
|
<thing-type id="airthings_wave_plus">
|
||||||
<supported-bridge-type-refs>
|
<supported-bridge-type-refs>
|
||||||
<bridge-type-ref id="roaming"/>
|
<bridge-type-ref id="roaming"/>
|
||||||
|
@ -11,7 +42,7 @@
|
||||||
<bridge-type-ref id="bluez"/>
|
<bridge-type-ref id="bluez"/>
|
||||||
</supported-bridge-type-refs>
|
</supported-bridge-type-refs>
|
||||||
|
|
||||||
<label>Airthings Wave+</label>
|
<label>Airthings Wave Plus</label>
|
||||||
<description>Indoor air quality monitor with radon detection</description>
|
<description>Indoor air quality monitor with radon detection</description>
|
||||||
|
|
||||||
<channels>
|
<channels>
|
||||||
|
@ -43,7 +74,7 @@
|
||||||
<item-type>Number:Dimensionless</item-type>
|
<item-type>Number:Dimensionless</item-type>
|
||||||
<label>Humidity</label>
|
<label>Humidity</label>
|
||||||
<description>Humidity level</description>
|
<description>Humidity level</description>
|
||||||
<state readOnly="true" pattern="%.1f %unit%"/>
|
<state readOnly="true" pattern="%.1f %%"/>
|
||||||
</channel-type>
|
</channel-type>
|
||||||
<channel-type id="airthings_temperature">
|
<channel-type id="airthings_temperature">
|
||||||
<item-type>Number:Temperature</item-type>
|
<item-type>Number:Temperature</item-type>
|
||||||
|
|
|
@ -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<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));
|
||||||
|
}
|
||||||
|
}
|
|
@ -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());
|
|
||||||
}
|
|
||||||
}
|
|
Loading…
Reference in New Issue