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
@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");
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";
// 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";
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;
}
}
+ 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);
}
}
if (apiConfig.hasCloudUrl()) {
return apiConfig.hostname + String.format(LEDS_MODE_PATH, serialNo, apiConfig.token);
} else {
- return apiConfig.hostname;
+ return generateConfigUrl(apiConfig);
}
}
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;
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.
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())) {
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);
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());
}
*/
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;
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;
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());
} 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());
}
}
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());
}
}
- } catch (AirGradientCommunicationException agce) {
- updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, agce.getMessage());
- }
- }
- private void setLedModeOnDevice(String mode) {
- try {
- apiController.setLedMode(getSerialNo(), mode);
- updateStatus(ThingStatus.ONLINE);
+ 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 calibrateCo2OnDevice() {
+ private void updateConfiguration(Consumer<LocalConfiguration> action) {
try {
- apiController.calibrateCo2(getSerialNo());
+ LocalConfiguration config = new LocalConfiguration();
+ action.accept(config);
+ apiController.setConfig(config);
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;
--- /dev/null
+/**
+ * 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);
+ }
+}
--- /dev/null
+/**
+ * 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);
+ }
+ }
+}
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) {
--- /dev/null
+/**
+ * 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
+}
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;
@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
@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
@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
<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>
</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>
}
};
+ 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;
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"));
+ }
}
}
};
+ 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}
""";
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));
+ }
}