[airgradient] Support firmware v3.1.1 and later (#16851)

* Support calibrated measurements from firmware v3.1.1

Signed-off-by: Jørgen Austvik <jaustvik@acm.org>
Signed-off-by: Ciprian Pascu <contact@ciprianpascu.ro>
This commit is contained in:
Jørgen Austvik 2024-06-10 23:15:08 +02:00 committed by Ciprian Pascu
parent e32dd1718a
commit 3ba3152077
13 changed files with 631 additions and 53 deletions

View File

@ -67,18 +67,40 @@ To add a location, you need to know the location ID. To get the location ID, you
For more information about the data in the channels, please refer to the models in https://api.airgradient.com/public/docs/api/v1/
| Channel | Type | Read/Write | Description |
|-------------|----------------------|------------|----------------------------------------------------------------------------------|
| pm01 | Number:Density | Read | Particulate Matter 1 (0.001mm) |
| pm02 | Number:Density | Read | Particulate Matter 2 (0.002mm) |
| pm10 | Number:Density | Read | Particulate Matter 10 (0.01mm) |
| pm003-count | Switch | Read | The number of particles with a diameter beyond 0.3 microns in 1 deciliter of air |
| rco2 | Number:Density | Read | Carbon dioxide PPM |
| tvoc | Number:Density | Read | Total Volatile Organic Compounds |
| atmp | Number:Temperature | Read | Ambient Temperature |
| rhum | Number:Dimensionless | Read | Relative Humidity Percentage |
| wifi | Number | Read | Received signal strength indicator |
| boot | Number:Dimensionless | Read | Number of measure uploads since last reboot (boot) |
| Channel | Type | Read/Write | Description |
|--------------------|----------------------|------------|----------------------------------------------------------------------------------|
| pm01 | Number:Density | Read | Particulate Matter 1 (0.001mm) |
| pm02 | Number:Density | Read | Particulate Matter 2 (0.002mm) |
| pm10 | Number:Density | Read | Particulate Matter 10 (0.01mm) |
| pm003-count | Number:Dimensionless | Read | The number of particles with a diameter beyond 0.3 microns in 1 deciliter of air |
| rco2 | Number:Density | Read | Carbon dioxide PPM |
| tvoc | Number:Density | Read | Total Volatile Organic Compounds |
| atmp | Number:Temperature | Read | Ambient Temperature |
| rhum | Number:Dimensionless | Read | Relative Humidity Percentage |
| wifi | Number | Read | Received signal strength indicator |
| uploads-since-boot | Number:Dimensionless | Read | Number of measure uploads since last reboot (boot) |
| leds | String | Read/Write | Sets the leds mode (off/co2/pm) |
| calibration | String | Write | Triggers co2 calibration on the device |
Some configuration channels are only available for local devices (for cloud devices use the AirGradient dashboard to configure these instead).
These configuration settings needs AirGradient firmware on the sensor of version 3.1.1 or later.
| Channel | Type | Read/Write | Description |
|-----------------------|----------------------|------------|----------------------------------------------------------------------------------|
| country-code | String | Read/Write | The ALPHA-2 Country code used for the device |
| pm-standard | String | Read/Write | Standard used for Parts per Million measurements (us-aqi or ugm3) |
| abc-days | Number:Days | Read/Write | Co2 calibration automatic baseline calibration days |
| tvoc-learning-offset | Number:Dimensionless | Read/Write | Time constant of long-term estimator for offset. |
| nox-learning-offset | Number:Dimensionless | Read/Write | Time constant of long-term estimator for offset. |
| mqtt-broker-url | String | Read/Write | MQTT Broker URL |
| temperature-unit | String | Read/Write | Temperature unit used on the display |
| configuration-control | String | Read/Write | Where the unit is configured from (local/cloud/both) |
| post-to-cloud | Switch | Read/Write | Send data to the AirGradient cloud |
| led-bar-brightness | Number:Dimensionless | Read/Write | Brightness of the LED bar |
| display-brightness | Number:Dimensionless | Read/Write | Brightness of the display |
| model | String | Read/Write | The model of the device (can be changed e.g. if you change sensors) |
| led-bar-test | String | Write | Trigger test of LED bar |
## Full Example

View File

@ -26,7 +26,7 @@ import org.openhab.core.thing.ThingTypeUID;
@NonNullByDefault
public class AirGradientBindingConstants {
private static final String BINDING_ID = "airgradient";
public static final String BINDING_ID = "airgradient";
// List of all Thing Type UIDs
public static final ThingTypeUID THING_TYPE_API = new ThingTypeUID(BINDING_ID, "airgradient-api");
@ -46,6 +46,19 @@ public class AirGradientBindingConstants {
public static final String CHANNEL_LEDS_MODE = "leds";
public static final String CHANNEL_CALIBRATION = "calibration";
public static final String CHANNEL_UPLOADS_SINCE_BOOT = "uploads-since-boot";
public static final String CHANNEL_COUNTRY_CODE = "country-code";
public static final String CHANNEL_PM_STANDARD = "pm-standard";
public static final String CHANNEL_ABC_DAYS = "abc-days";
public static final String CHANNEL_TVOC_LEARNING_OFFSET = "tvoc-learning-offset";
public static final String CHANNEL_NOX_LEARNING_OFFSET = "nox-learning-offset";
public static final String CHANNEL_MQTT_BROKER_URL = "mqtt-broker-url";
public static final String CHANNEL_TEMPERATURE_UNIT = "temperature-unit";
public static final String CHANNEL_CONFIGURATION_CONTROL = "configuration-control";
public static final String CHANNEL_POST_TO_CLOUD = "post-to-cloud";
public static final String CHANNEL_LED_BAR_BRIGHTNESS = "led-bar-brightness";
public static final String CHANNEL_DISPLAY_BRIGHTNESS = "display-brightness";
public static final String CHANNEL_MODEL = "model";
public static final String CHANNEL_LED_BAR_TEST = "led-bar-test";
// List of all properties
public static final String PROPERTY_NAME = "name";
@ -59,6 +72,7 @@ public class AirGradientBindingConstants {
// URLs for API
public static final String CURRENT_MEASURES_PATH = "/public/api/v1/locations/measures/current?token=%s";
public static final String CURRENT_MEASURES_LOCAL_PATH = "/measures/current";
public static final String LOCAL_CONFIG_PATH = "/config";
public static final String LEDS_MODE_PATH = "/public/api/v1/sensors/%s/config/leds/mode?token=%s";
public static final String CALIBRATE_CO2_PATH = "/public/api/v1/sensors/%s/co2/calibration?token=%s";

View File

@ -15,8 +15,10 @@ package org.openhab.binding.airgradient.internal.communication;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CALIBRATE_CO2_PATH;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CURRENT_MEASURES_PATH;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.LEDS_MODE_PATH;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.LOCAL_CONFIG_PATH;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.REQUEST_TIMEOUT;
import java.net.URI;
import java.util.concurrent.TimeUnit;
import org.eclipse.jdt.annotation.NonNullByDefault;
@ -42,11 +44,17 @@ public class RESTHelper {
}
}
public static @Nullable String generateConfigUrl(AirGradientAPIConfiguration apiConfig) {
URI uri = URI.create(apiConfig.hostname);
URI configUri = uri.resolve(LOCAL_CONFIG_PATH);
return configUri.toString();
}
public static @Nullable String generateCalibrationCo2Url(AirGradientAPIConfiguration apiConfig, String serialNo) {
if (apiConfig.hasCloudUrl()) {
return apiConfig.hostname + String.format(CALIBRATE_CO2_PATH, serialNo, apiConfig.token);
} else {
return apiConfig.hostname;
return generateConfigUrl(apiConfig);
}
}
@ -54,7 +62,7 @@ public class RESTHelper {
if (apiConfig.hasCloudUrl()) {
return apiConfig.hostname + String.format(LEDS_MODE_PATH, serialNo, apiConfig.token);
} else {
return apiConfig.hostname;
return generateConfigUrl(apiConfig);
}
}

View File

@ -17,6 +17,7 @@ import static org.openhab.binding.airgradient.internal.AirGradientBindingConstan
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CONTENTTYPE_TEXT;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.REQUEST_TIMEOUT;
import java.lang.reflect.Type;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.List;
@ -35,11 +36,13 @@ import org.eclipse.jetty.http.HttpMethod;
import org.eclipse.jetty.http.HttpStatus;
import org.openhab.binding.airgradient.internal.config.AirGradientAPIConfiguration;
import org.openhab.binding.airgradient.internal.model.LedMode;
import org.openhab.binding.airgradient.internal.model.LocalConfiguration;
import org.openhab.binding.airgradient.internal.model.Measure;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import com.google.gson.Gson;
import com.google.gson.reflect.TypeToken;
/**
* Helper for doing rest calls to the AirGradient API.
@ -72,7 +75,7 @@ public class RemoteAPIController {
RESTHelper.generateRequest(httpClient, RESTHelper.generateMeasuresUrl(apiConfig)));
if (response != null) {
String contentType = response.getMediaType();
logger.debug("Got measurements with status {}: {} ({})", response.getStatus(),
logger.trace("Got measurements with status {}: {} ({})", response.getStatus(),
response.getContentAsString(), contentType);
if (HttpStatus.isSuccess(response.getStatus())) {
@ -96,6 +99,31 @@ public class RemoteAPIController {
return Collections.emptyList();
}
public @Nullable LocalConfiguration getConfig() throws AirGradientCommunicationException {
ContentResponse response = sendRequest(
RESTHelper.generateRequest(httpClient, RESTHelper.generateConfigUrl(apiConfig)));
if (response == null) {
return null;
}
logger.trace("Got configuration with status {}: {}", response.getStatus(), response.getContentAsString());
Type configType = new TypeToken<LocalConfiguration>() {
}.getType();
return gson.fromJson(response.getContentAsString(), configType);
}
public void setConfig(LocalConfiguration config) throws AirGradientCommunicationException {
Request request = httpClient.newRequest(RESTHelper.generateConfigUrl(apiConfig));
request.timeout(REQUEST_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
request.method(HttpMethod.PUT);
request.header(HttpHeader.CONTENT_TYPE, CONTENTTYPE_JSON);
String configJson = gson.toJson(config);
logger.debug("Setting configuration: {}", configJson);
request.content(new StringContentProvider(CONTENTTYPE_JSON, configJson, StandardCharsets.UTF_8));
sendRequest(request);
}
public void setLedMode(String serialNo, String mode) throws AirGradientCommunicationException {
Request request = httpClient.newRequest(RESTHelper.generateGetLedsModeUrl(apiConfig, serialNo));
request.timeout(REQUEST_TIMEOUT.toMillis(), TimeUnit.MILLISECONDS);
@ -126,7 +154,8 @@ public class RemoteAPIController {
try {
response = request.send();
if (response != null) {
logger.debug("Response from {}: {}", request.getURI(), response.getStatus());
logger.trace("Response from {} ({}): {}", request.getURI(), response.getStatus(),
response.getContentAsString());
if (!HttpStatus.isSuccess(response.getStatus())) {
throw new AirGradientCommunicationException("Returned status code: " + response.getStatus());
}

View File

@ -12,13 +12,13 @@
*/
package org.openhab.binding.airgradient.internal.handler;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CHANNEL_CALIBRATION;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.CHANNEL_LEDS_MODE;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.*;
import java.util.List;
import java.util.Map;
import java.util.concurrent.ScheduledFuture;
import java.util.concurrent.TimeUnit;
import java.util.function.Consumer;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
@ -26,13 +26,17 @@ import org.eclipse.jetty.client.HttpClient;
import org.openhab.binding.airgradient.internal.communication.AirGradientCommunicationException;
import org.openhab.binding.airgradient.internal.communication.RemoteAPIController;
import org.openhab.binding.airgradient.internal.config.AirGradientAPIConfiguration;
import org.openhab.binding.airgradient.internal.model.LocalConfiguration;
import org.openhab.binding.airgradient.internal.model.Measure;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.ThingStatus;
import org.openhab.core.thing.ThingStatusDetail;
import org.openhab.core.thing.binding.BaseThingHandler;
import org.openhab.core.thing.binding.builder.ThingBuilder;
import org.openhab.core.types.Command;
import org.openhab.core.types.RefreshType;
import org.openhab.core.types.State;
@ -72,7 +76,7 @@ public class AirGradientLocalHandler extends BaseThingHandler {
pollingCode();
} else if (CHANNEL_LEDS_MODE.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
setLedModeOnDevice(stringCommand.toFullString());
updateConfiguration((var c) -> c.ledBarMode = stringCommand.toFullString());
} else {
logger.warn("Received command {} for channel {}, but it needs a string command", command.toString(),
channelUID.getId());
@ -80,17 +84,63 @@ public class AirGradientLocalHandler extends BaseThingHandler {
} else if (CHANNEL_CALIBRATION.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
if ("co2".equals(stringCommand.toFullString())) {
calibrateCo2OnDevice();
updateConfiguration((var c) -> c.co2CalibrationRequested = true);
} else {
logger.warn(
"Received unknown command {} for calibration on channel {}, which we don't know how to handle",
command.toString(), channelUID.getId());
}
}
} else if (CHANNEL_TEMPERATURE_UNIT.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
updateConfiguration((var c) -> c.temperatureUnit = stringCommand.toFullString());
}
} else if (CHANNEL_PM_STANDARD.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
updateConfiguration((var c) -> c.pmStandard = stringCommand.toFullString());
}
} else if (CHANNEL_ABC_DAYS.equals(channelUID.getId())) {
if (command instanceof QuantityType quantityCommand) {
updateConfiguration((var c) -> c.abcDays = quantityCommand.longValue());
}
} else if (CHANNEL_TVOC_LEARNING_OFFSET.equals(channelUID.getId())) {
if (command instanceof QuantityType quantityCommand) {
updateConfiguration((var c) -> c.tvocLearningOffset = quantityCommand.longValue());
}
} else if (CHANNEL_NOX_LEARNING_OFFSET.equals(channelUID.getId())) {
if (command instanceof QuantityType quantityCommand) {
updateConfiguration((var c) -> c.noxLearningOffset = quantityCommand.longValue());
}
} else if (CHANNEL_MQTT_BROKER_URL.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
updateConfiguration((var c) -> c.mqttBrokerUrl = stringCommand.toFullString());
}
} else if (CHANNEL_CONFIGURATION_CONTROL.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
updateConfiguration((var c) -> c.configurationControl = stringCommand.toFullString());
}
} else if (CHANNEL_LED_BAR_BRIGHTNESS.equals(channelUID.getId())) {
if (command instanceof QuantityType quantityCommand) {
updateConfiguration((var c) -> c.ledBarBrightness = quantityCommand.longValue());
}
} else if (CHANNEL_DISPLAY_BRIGHTNESS.equals(channelUID.getId())) {
if (command instanceof QuantityType quantityCommand) {
updateConfiguration((var c) -> c.displayBrightness = quantityCommand.longValue());
}
} else if (CHANNEL_POST_TO_CLOUD.equals(channelUID.getId())) {
if (command instanceof OnOffType onOffCommand) {
updateConfiguration((var c) -> c.postDataToAirGradient = onOffCommand.equals(OnOffType.ON));
}
} else if (CHANNEL_MODEL.equals(channelUID.getId())) {
if (command instanceof StringType stringCommand) {
updateConfiguration((var c) -> c.model = stringCommand.toFullString());
}
} else if (CHANNEL_LED_BAR_TEST.equals(channelUID.getId())) {
updateConfiguration((var c) -> c.ledBarTestRequested = true);
} else {
// This is read only
logger.warn("Received command {} for channel {}, which we don't know how to handle", command.toString(),
channelUID.getId());
logger.warn("Received command {} for channel {}, which we don't know how to handle (type: {})",
command.toString(), channelUID.getId(), command.getClass());
}
}
@ -124,50 +174,46 @@ public class AirGradientLocalHandler extends BaseThingHandler {
return;
}
updateProperties(MeasureHelper.createProperties(measures.get(0)));
Map<String, State> states = MeasureHelper.createStates(measures.get(0));
Measure measure = measures.get(0);
updateProperties(MeasureHelper.createProperties(measure));
Map<String, State> states = MeasureHelper.createStates(measure);
for (Map.Entry<String, State> entry : states.entrySet()) {
if (isLinked(entry.getKey())) {
updateState(entry.getKey(), entry.getValue());
}
}
LocalConfiguration localConfig = apiController.getConfig();
if (localConfig != null) {
// If we are able to read config, we add config channels
ThingBuilder builder = DynamicChannelHelper.updateThingWithConfigurationChannels(thing, editThing());
updateThing(builder.build());
updateProperties(ConfigurationHelper.createProperties(localConfig));
Map<String, State> configStates = ConfigurationHelper.createStates(localConfig);
for (Map.Entry<String, State> entry : configStates.entrySet()) {
if (isLinked(entry.getKey())) {
updateState(entry.getKey(), entry.getValue());
}
}
}
} catch (AirGradientCommunicationException agce) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage());
}
}
private void setLedModeOnDevice(String mode) {
private void updateConfiguration(Consumer<LocalConfiguration> action) {
try {
apiController.setLedMode(getSerialNo(), mode);
LocalConfiguration config = new LocalConfiguration();
action.accept(config);
apiController.setConfig(config);
updateStatus(ThingStatus.ONLINE);
} catch (AirGradientCommunicationException agce) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage());
}
}
private void calibrateCo2OnDevice() {
try {
apiController.calibrateCo2(getSerialNo());
updateStatus(ThingStatus.ONLINE);
} catch (AirGradientCommunicationException agce) {
updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage());
}
}
/**
* Returns the serial number of this sensor.
*
* @return serial number of this sensor.
*/
public String getSerialNo() {
String serialNo = thing.getProperties().get(Thing.PROPERTY_SERIAL_NUMBER);
if (serialNo == null) {
serialNo = "";
}
return serialNo;
}
@Override
public void dispose() {
ScheduledFuture<?> pollingJob = this.pollingJob;

View File

@ -0,0 +1,84 @@
/**
* Copyright (c) 2010-2024 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.airgradient.internal.handler;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.*;
import java.util.HashMap;
import java.util.Map;
import javax.measure.Unit;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
import org.openhab.binding.airgradient.internal.model.LocalConfiguration;
import org.openhab.core.library.types.OnOffType;
import org.openhab.core.library.types.QuantityType;
import org.openhab.core.library.types.StringType;
import org.openhab.core.library.unit.Units;
import org.openhab.core.thing.Thing;
import org.openhab.core.types.State;
import org.openhab.core.types.UnDefType;
/**
* Helper class to reduce code duplication across things.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class ConfigurationHelper {
public static Map<String, String> createProperties(LocalConfiguration configuration) {
Map<String, String> properties = new HashMap<>(4);
String model = configuration.model;
if (model != null) {
properties.put(Thing.PROPERTY_MODEL_ID, model);
}
return properties;
}
public static final String CHANNEL_POST_TO_CLOUD = "post-to-cloud";
public static Map<String, State> createStates(LocalConfiguration configuration) {
Map<String, State> states = new HashMap<>(11);
states.put(CHANNEL_COUNTRY_CODE, toStringType(configuration.country));
states.put(CHANNEL_PM_STANDARD, toStringType(configuration.pmStandard));
states.put(CHANNEL_ABC_DAYS, toQuantityType(configuration.abcDays, Units.DAY));
states.put(CHANNEL_TVOC_LEARNING_OFFSET, toQuantityType(configuration.tvocLearningOffset, Units.ONE));
states.put(CHANNEL_NOX_LEARNING_OFFSET, toQuantityType(configuration.noxLearningOffset, Units.ONE));
states.put(CHANNEL_MQTT_BROKER_URL, toStringType(configuration.mqttBrokerUrl));
states.put(CHANNEL_TEMPERATURE_UNIT, toStringType(configuration.temperatureUnit));
states.put(CHANNEL_CONFIGURATION_CONTROL, toStringType(configuration.configurationControl));
states.put(CHANNEL_LED_BAR_BRIGHTNESS, toQuantityType(configuration.ledBarBrightness, Units.ONE));
states.put(CHANNEL_DISPLAY_BRIGHTNESS, toQuantityType(configuration.displayBrightness, Units.ONE));
states.put(CHANNEL_POST_TO_CLOUD, toOnOffType(configuration.postDataToAirGradient));
states.put(CHANNEL_MODEL, toStringType(configuration.model));
return states;
}
private static State toQuantityType(@Nullable Number value, Unit<?> unit) {
return value == null ? UnDefType.NULL : new QuantityType<>(value, unit);
}
private static State toStringType(@Nullable String value) {
return value == null ? UnDefType.NULL : StringType.valueOf(value);
}
private static State toOnOffType(@Nullable Boolean value) {
return value == null ? UnDefType.NULL : OnOffType.from(value);
}
}

View File

@ -0,0 +1,81 @@
/**
* Copyright (c) 2010-2024 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.airgradient.internal.handler;
import static org.openhab.binding.airgradient.internal.AirGradientBindingConstants.*;
import java.util.ArrayList;
import java.util.List;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.openhab.core.thing.Channel;
import org.openhab.core.thing.ChannelUID;
import org.openhab.core.thing.Thing;
import org.openhab.core.thing.binding.builder.ChannelBuilder;
import org.openhab.core.thing.binding.builder.ThingBuilder;
import org.openhab.core.thing.type.ChannelTypeUID;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
/**
* The {@link DynamicChannelHelper} is responsible for creating dynamic configuration channels.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class DynamicChannelHelper {
private record ConfigurationChannel(String id, String typeId, String itemType) {
}
private final static List<ConfigurationChannel> channels = new ArrayList<ConfigurationChannel>() {
{
add(new ConfigurationChannel(CHANNEL_COUNTRY_CODE, CHANNEL_COUNTRY_CODE, "String"));
add(new ConfigurationChannel(CHANNEL_PM_STANDARD, CHANNEL_PM_STANDARD, "String"));
add(new ConfigurationChannel(CHANNEL_ABC_DAYS, CHANNEL_ABC_DAYS, "Number"));
add(new ConfigurationChannel(CHANNEL_TVOC_LEARNING_OFFSET, CHANNEL_TVOC_LEARNING_OFFSET, "Number"));
add(new ConfigurationChannel(CHANNEL_NOX_LEARNING_OFFSET, CHANNEL_NOX_LEARNING_OFFSET, "Number"));
add(new ConfigurationChannel(CHANNEL_MQTT_BROKER_URL, CHANNEL_MQTT_BROKER_URL, "String"));
add(new ConfigurationChannel(CHANNEL_TEMPERATURE_UNIT, CHANNEL_TEMPERATURE_UNIT, "String"));
add(new ConfigurationChannel(CHANNEL_CONFIGURATION_CONTROL, CHANNEL_CONFIGURATION_CONTROL, "String"));
add(new ConfigurationChannel(CHANNEL_POST_TO_CLOUD, CHANNEL_POST_TO_CLOUD, "Switch"));
add(new ConfigurationChannel(CHANNEL_LED_BAR_BRIGHTNESS, CHANNEL_LED_BAR_BRIGHTNESS,
"Number:Dimensionless"));
add(new ConfigurationChannel(CHANNEL_DISPLAY_BRIGHTNESS, CHANNEL_DISPLAY_BRIGHTNESS,
"Number:Dimensionless"));
add(new ConfigurationChannel(CHANNEL_MODEL, CHANNEL_MODEL, "String"));
add(new ConfigurationChannel(CHANNEL_LED_BAR_TEST, CHANNEL_LED_BAR_TEST, "String"));
}
};
private final static Logger logger = LoggerFactory.getLogger(DynamicChannelHelper.class);
public static ThingBuilder updateThingWithConfigurationChannels(Thing thing, ThingBuilder builder) {
for (ConfigurationChannel channel : channels) {
addLocalConfigurationChannel(thing, builder, channel);
}
return builder;
}
private static void addLocalConfigurationChannel(Thing originalThing, ThingBuilder builder,
ConfigurationChannel toAdd) {
ChannelUID channelId = new ChannelUID(originalThing.getUID(), toAdd.id);
if (originalThing.getChannel(channelId) == null) {
logger.debug("Adding dynamic channel {} to {}", toAdd.id, originalThing.getUID());
ChannelTypeUID typeId = new ChannelTypeUID(BINDING_ID, toAdd.typeId);
Channel channel = ChannelBuilder.create(channelId, toAdd.itemType).withType(typeId).build();
builder.withChannel(channel);
}
}
}

View File

@ -66,13 +66,13 @@ public class MeasureHelper {
public static Map<String, State> createStates(Measure measure) {
Map<String, State> states = new HashMap<>(11);
states.put(CHANNEL_ATMP, toQuantityType(measure.atmp, SIUnits.CELSIUS));
states.put(CHANNEL_ATMP, toQuantityType(measure.getTemperature(), SIUnits.CELSIUS));
states.put(CHANNEL_PM_003_COUNT, toQuantityType(measure.pm003Count, Units.ONE));
states.put(CHANNEL_PM_01, toQuantityType(measure.pm01, Units.MICROGRAM_PER_CUBICMETRE));
states.put(CHANNEL_PM_02, toQuantityType(measure.pm02, Units.MICROGRAM_PER_CUBICMETRE));
states.put(CHANNEL_PM_10, toQuantityType(measure.pm10, Units.MICROGRAM_PER_CUBICMETRE));
states.put(CHANNEL_RHUM, toQuantityType(measure.rhum, Units.PERCENT));
states.put(CHANNEL_UPLOADS_SINCE_BOOT, toQuantityType(measure.boot, Units.ONE));
states.put(CHANNEL_RHUM, toQuantityType(measure.getHumidity(), Units.PERCENT));
states.put(CHANNEL_UPLOADS_SINCE_BOOT, toQuantityType(measure.getBootCount(), Units.ONE));
Double rco2 = measure.rco2;
if (rco2 != null) {

View File

@ -0,0 +1,75 @@
/**
* Copyright (c) 2010-2024 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.airgradient.internal.model;
import org.eclipse.jdt.annotation.NonNullByDefault;
import org.eclipse.jdt.annotation.Nullable;
/**
* Data model class for configuration from a local sensor.
*
* @author Jørgen Austvik - Initial contribution
*/
@NonNullByDefault
public class LocalConfiguration {
@Nullable
public String country; // ALPHA-2 Country code
@Nullable
public String pmStandard; // usaqi/ugm3
@Nullable
public String ledBarMode; // off, pm, co2
@Nullable
public Long abcDays; // Co2 calibration automatic baseline calibration days ( 0-200)
@Nullable
public Long tvocLearningOffset; // Time constant of long-term estimator for offset. Past events will be forgotten
// after about twice the learning time. Range 1..1000 [hours]
@Nullable
public Long noxLearningOffset; // Time constant of long-term estimator for offset. Past events will be forgotten
// after about twice the learning time. Range 1..1000 [hours]
@Nullable
public String mqttBrokerUrl;
@Nullable
public String temperatureUnit; // c/f
@Nullable
public String configurationControl; // local, cloud, both
@Nullable
public Boolean postDataToAirGradient;
@Nullable
public Long ledBarBrightness; // 0 - 100
@Nullable
public Long displayBrightness; // 0 - 100
@Nullable
public Boolean offlineMode; // Don't connect to wifi
@Nullable
public String model;
@Nullable
public Boolean co2CalibrationRequested; // TRIGGER: Calibration of Co2 sensor
@Nullable
public Boolean ledBarTestRequested; // TRIGGER: LEDs will run test sequence
}

View File

@ -91,6 +91,30 @@ public class Measure {
return null;
}
public @Nullable Long getBootCount() {
if (bootCount == null) {
return boot;
}
return bootCount;
}
public @Nullable Double getTemperature() {
if (atmpCompensated == null) {
return atmp;
}
return atmpCompensated;
}
public @Nullable Double getHumidity() {
if (rhumCompensated == null) {
return rhum;
}
return rhumCompensated;
}
@Nullable
public String locationId;
@ -115,9 +139,15 @@ public class Measure {
@Nullable
public Double atmp; // The ambient temperature in celsius
@Nullable
public Double atmpCompensated; // The ambient temperature, compensated for sensor inaccuracies
@Nullable
public Double rhum; // The relative humidity in percent
@Nullable
public Double rhumCompensated; // The relative humidity in percent, compensated for sensor inaccuracies
@Nullable
public Double rco2; // The CO2 value in ppm
@ -127,9 +157,15 @@ public class Measure {
@Nullable
public Double tvocIndex; // The value of the TVOC index, sensor model dependent
@Nullable
public Double tvocRaw; // Raw data from TVOC senosor
@Nullable
public Double noxIndex; // The value of the NOx index, sensor model dependent
@Nullable
public Double noxRaw; // Raw data from NOx sensor
@Nullable
public Double wifi; // The wifi signal strength in dBm
@ -157,6 +193,9 @@ public class Measure {
@Nullable
public Long boot; // Number of times sensor has uploaded data since last reboot
@Nullable
public Long bootCount; // Same as boot, in firmwares > v3
@Nullable
public String fwMode; // Model of sensor from local API

View File

@ -50,6 +50,21 @@
<channel id="leds" typeId="leds-mode"/>
<channel id="calibration" typeId="calibration"/>
<channel id="uploads-since-boot" typeId="uploads-since-boot"/>
<!-- These are added dynamically if the device supports them
<channel id="country-code" typeId="country-code"/>
<channel id="pm-standard" typeId="pm-standard"/>
<channel id="abc-days" typeId="abc-days"/>
<channel id="tvoc-learning-offset" typeId="tvoc-learning-offset"/>
<channel id="nox-learning-offset" typeId="nox-learning-offset"/>
<channel id="mqtt-broker-url" typeId="mqtt-broker-url"/>
<channel id="temperature-unit" typeId="temperature-unit"/>
<channel id="configuration-control" typeId="configuration-control"/>
<channel id="post-to-cloud" typeId="post-to-cloud"/>
<channel id="led-bar-brightness" typeId="led-bar-brightness"/>
<channel id="display-brightness" typeId="display-brightness"/>
<channel id="model" typeId="model"/>
<channel id="led-bar-test" typeId="led-bar-test"/>
-->
</channels>
<properties>
@ -199,4 +214,113 @@
</command>
</channel-type>
<channel-type id="country-code">
<item-type>String</item-type>
<label>Country code</label>
<description>2 digit country code (ALPHA-2)</description>
<state readOnly="false"/>
</channel-type>
<channel-type id="pm-standard">
<item-type>String</item-type>
<label>Parts per Million Standard</label>
<description>Standard used for Parts per Million measurements</description>
<state readOnly="false">
<options>
<option value="us-aqi">USAqi</option>
<option value="ugm3">ugm3</option>
</options>
</state>
</channel-type>
<channel-type id="abc-days">
<item-type>Number</item-type>
<label>Automatic Baseline Calibration (Days)</label>
<description>Co2 calibration automatic baseline calibration days</description>
<state min="0" max="200" step="1" readOnly="false" pattern="%d %unit%"/>
</channel-type>
<channel-type id="tvoc-learning-offset">
<item-type>Number</item-type>
<label>TVOC learnings offset (hours)</label>
<description>Time constant of long-term estimator for offset. Past events will be forgotten after about twice the
learning time.</description>
<state min="0" max="1000" step="1" readOnly="false" pattern="%d %unit%"/>
</channel-type>
<channel-type id="nox-learning-offset">
<item-type>Number</item-type>
<label>NOX learnings offset (hours)</label>
<description>Time constant of long-term estimator for offset. Past events will be forgotten after about twice the
learning time.</description>
<state min="0" max="1000" step="1" readOnly="false" pattern="%d %unit%"/>
</channel-type>
<channel-type id="mqtt-broker-url">
<item-type>String</item-type>
<label>MQTT Broker URL</label>
<description>MQTT Broker URL</description>
<state readOnly="false"/>
</channel-type>
<channel-type id="temperature-unit">
<item-type>String</item-type>
<label>Temperature Unit</label>
<description>Temperature unit used on the display</description>
<state readOnly="false">
<options>
<option value="c">Celsius</option>
<option value="f">Fahrenheit</option>
</options>
</state>
</channel-type>
<channel-type id="configuration-control">
<item-type>String</item-type>
<label>Configuration Control</label>
<description>Where the unit is configured from</description>
<state readOnly="false">
<options>
<option value="both">Both</option>
<option value="local">Local</option>
<option value="cloud">Cloud</option>
</options>
</state>
</channel-type>
<channel-type id="post-to-cloud">
<item-type>Switch</item-type>
<label>Send to cloud</label>
<description>Send data to the AirGradient cloud</description>
<state readOnly="false"/>
</channel-type>
<channel-type id="led-bar-brightness">
<item-type>Number:Dimensionless</item-type>
<label>Led bar brightness</label>
<description>Brightness of the LED bar.</description>
<state min="0" max="100" step="1" readOnly="false" pattern="%d"/>
</channel-type>
<channel-type id="display-brightness">
<item-type>Number:Dimensionless</item-type>
<label>Display brightness</label>
<description>Brightness of the display.</description>
<state min="0" max="100" step="1" readOnly="false" pattern="%d"/>
</channel-type>
<channel-type id="model">
<item-type>String</item-type>
<label>Model</label>
<description>Model of the device</description>
<state readOnly="true"/>
</channel-type>
<channel-type id="led-bar-test">
<item-type>String</item-type>
<label>LED Bar test</label>
<description>Test LED bar</description>
<state readOnly="false"/>
</channel-type>
</thing:thing-descriptions>

View File

@ -58,6 +58,19 @@ public class AirGradientLocationHandlerTest {
}
};
private static final Measure TEST_MEASURE_V3_1_1 = new Measure() {
{
locationId = "12345";
locationName = "Location name";
timestamp = "2024-01-07T11:28:56.000Z";
serialno = "ecda3b1a2a50";
firmwareVersion = "3.1.1";
atmpCompensated = 24.2;
rhumCompensated = 36d;
bootCount = 16l;
}
};
@Nullable
private AirGradientLocationHandler sut;
@ -102,4 +115,18 @@ public class AirGradientLocationHandlerTest {
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_TVOC),
new QuantityType<>("51 ppb"));
}
// Firmware Version 3.1.1 has slight changes in the Json
@Test
public void testSetMeasureVersion3_1_1() {
sut.setCallback(callbackMock);
sut.setMeasurment(TEST_MEASURE_V3_1_1);
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_ATMP),
new QuantityType<>("24.2 °C"));
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_RHUM),
new QuantityType<>("36 %"));
verify(callbackMock).stateUpdated(new ChannelUID(sut.getThing().getUID(), CHANNEL_UPLOADS_SINCE_BOOT),
new QuantityType<>("16"));
}
}

View File

@ -49,6 +49,10 @@ public class RemoteApiControllerTest {
}
};
private static final String SINGLE_CONFIG = """
{"country":"NO","pmStandard":"ugm3","ledBarMode":"off","abcDays":8,"tvocLearningOffset":12,"noxLearningOffset":12,"mqttBrokerUrl":"https://192.168.1.1/mqtt","temperatureUnit":"c","configurationControl":"both","postDataToAirGradient":true,"ledBarBrightness":100,"displayBrightness":100,"offlineMode":false,"model":"I-9PSL"}
""";
private static final String SINGLE_CONTENT = """
{"locationId":4321,"locationName":"Some other name","pm01":1,"pm02":2,"pm10":2,"pm003Count":536,"atmp":20.45,"rhum":16.61,"rco2":456,"tvoc":51.644928,"wifi":-54,"timestamp":"2024-01-07T13:00:20.000Z","ledMode":"co2","ledCo2Threshold1":1000,"ledCo2Threshold2":2000,"ledCo2ThresholdEnd":4000,"serialno":"serial","firmwareVersion":null,"tvocIndex":null,"noxIndex":null}
""";
@ -259,4 +263,29 @@ public class RemoteApiControllerTest {
assertThat(res.get(0).noxIndex, closeTo(1, 0.1));
assertThat(res.get(0).serialno, is("4XXXXXXXXXXc"));
}
@Test
public void testGetConfig() throws Exception {
ContentResponse response = Mockito.mock(ContentResponse.class);
Mockito.when(httpClientMock.newRequest(anyString())).thenReturn(requestMock);
Mockito.when(requestMock.send()).thenReturn(response);
Mockito.when(response.getStatus()).thenReturn(200);
Mockito.when(response.getMediaType()).thenReturn("application/json");
Mockito.when(response.getContentAsString()).thenReturn(SINGLE_CONFIG);
var res = sut.getConfig();
assertThat(res.abcDays, is(8L));
assertThat(res.configurationControl, is("both"));
assertThat(res.country, is("NO"));
assertThat(res.displayBrightness, is(100L));
assertThat(res.ledBarBrightness, is(100L));
assertThat(res.ledBarMode, is("off"));
assertThat(res.model, is("I-9PSL"));
assertThat(res.mqttBrokerUrl, is("https://192.168.1.1/mqtt"));
assertThat(res.noxLearningOffset, is(12L));
assertThat(res.pmStandard, is("ugm3"));
assertThat(res.postDataToAirGradient, is(true));
assertThat(res.temperatureUnit, is("c"));
assertThat(res.tvocLearningOffset, is(12L));
}
}