From b25acdfb1275d2e240e4f0a02a132288fe853e26 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B8rgen=20Austvik?= Date: Mon, 10 Jun 2024 23:15:08 +0200 Subject: [PATCH] [airgradient] Support firmware v3.1.1 and later (#16851) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Support calibrated measurements from firmware v3.1.1 Signed-off-by: Jørgen Austvik --- .../org.openhab.binding.airgradient/README.md | 46 +++++-- .../internal/AirGradientBindingConstants.java | 16 ++- .../internal/communication/RESTHelper.java | 12 +- .../communication/RemoteAPIController.java | 33 ++++- .../handler/AirGradientLocalHandler.java | 112 +++++++++++----- .../internal/handler/ConfigurationHelper.java | 84 ++++++++++++ .../handler/DynamicChannelHelper.java | 81 ++++++++++++ .../internal/handler/MeasureHelper.java | 6 +- .../internal/model/LocalConfiguration.java | 75 +++++++++++ .../airgradient/internal/model/Measure.java | 39 ++++++ .../resources/OH-INF/thing/thing-types.xml | 124 ++++++++++++++++++ .../AirGradientLocationHandlerTest.java | 27 ++++ .../handler/RemoteApiControllerTest.java | 29 ++++ 13 files changed, 631 insertions(+), 53 deletions(-) create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/ConfigurationHelper.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/DynamicChannelHelper.java create mode 100644 bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/LocalConfiguration.java diff --git a/bundles/org.openhab.binding.airgradient/README.md b/bundles/org.openhab.binding.airgradient/README.md index 2a901f9885f..b29157ddabb 100644 --- a/bundles/org.openhab.binding.airgradient/README.md +++ b/bundles/org.openhab.binding.airgradient/README.md @@ -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 diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/AirGradientBindingConstants.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/AirGradientBindingConstants.java index 839e5d7c6ba..af0a37bbd11 100644 --- a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/AirGradientBindingConstants.java +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/AirGradientBindingConstants.java @@ -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"; diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RESTHelper.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RESTHelper.java index a522e1aa06d..04db1ab3c1e 100644 --- a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RESTHelper.java +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RESTHelper.java @@ -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); } } diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RemoteAPIController.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RemoteAPIController.java index d4d8a6ba4c6..47c8f716443 100644 --- a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RemoteAPIController.java +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/communication/RemoteAPIController.java @@ -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() { + }.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()); } diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocalHandler.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocalHandler.java index 03878c26b71..7703f17197f 100644 --- a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocalHandler.java +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocalHandler.java @@ -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 states = MeasureHelper.createStates(measures.get(0)); + Measure measure = measures.get(0); + updateProperties(MeasureHelper.createProperties(measure)); + Map states = MeasureHelper.createStates(measure); for (Map.Entry 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 configStates = ConfigurationHelper.createStates(localConfig); + for (Map.Entry 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 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; diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/ConfigurationHelper.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/ConfigurationHelper.java new file mode 100644 index 00000000000..4aea8bf3d15 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/ConfigurationHelper.java @@ -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 createProperties(LocalConfiguration configuration) { + Map 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 createStates(LocalConfiguration configuration) { + Map 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); + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/DynamicChannelHelper.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/DynamicChannelHelper.java new file mode 100644 index 00000000000..4fbd5f2b4f1 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/DynamicChannelHelper.java @@ -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 channels = new ArrayList() { + { + 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); + } + } +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/MeasureHelper.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/MeasureHelper.java index 41e724e3937..9cfee9ccfa1 100644 --- a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/MeasureHelper.java +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/handler/MeasureHelper.java @@ -66,13 +66,13 @@ public class MeasureHelper { public static Map createStates(Measure measure) { Map 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) { diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/LocalConfiguration.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/LocalConfiguration.java new file mode 100644 index 00000000000..aa6398e71b5 --- /dev/null +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/LocalConfiguration.java @@ -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 +} diff --git a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/Measure.java b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/Measure.java index 726ca276c80..924a8ace0cd 100644 --- a/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/Measure.java +++ b/bundles/org.openhab.binding.airgradient/src/main/java/org/openhab/binding/airgradient/internal/model/Measure.java @@ -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 diff --git a/bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/thing/thing-types.xml b/bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/thing/thing-types.xml index b4557920723..b1fd1adaa14 100644 --- a/bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/thing/thing-types.xml +++ b/bundles/org.openhab.binding.airgradient/src/main/resources/OH-INF/thing/thing-types.xml @@ -50,6 +50,21 @@ + @@ -199,4 +214,113 @@ + + String + + 2 digit country code (ALPHA-2) + + + + + String + + Standard used for Parts per Million measurements + + + + + + + + + + Number + + Co2 calibration automatic baseline calibration days + + + + + Number + + Time constant of long-term estimator for offset. Past events will be forgotten after about twice the + learning time. + + + + + Number + + Time constant of long-term estimator for offset. Past events will be forgotten after about twice the + learning time. + + + + + String + + MQTT Broker URL + + + + + String + + Temperature unit used on the display + + + + + + + + + + String + + Where the unit is configured from + + + + + + + + + + + Switch + + Send data to the AirGradient cloud + + + + + Number:Dimensionless + + Brightness of the LED bar. + + + + + Number:Dimensionless + + Brightness of the display. + + + + + String + + Model of the device + + + + + String + + Test LED bar + + + diff --git a/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocationHandlerTest.java b/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocationHandlerTest.java index d0687866719..2cd75f8ca30 100644 --- a/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocationHandlerTest.java +++ b/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/AirGradientLocationHandlerTest.java @@ -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")); + } } diff --git a/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/RemoteApiControllerTest.java b/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/RemoteApiControllerTest.java index f71bc1bed7d..727b1a0df78 100644 --- a/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/RemoteApiControllerTest.java +++ b/bundles/org.openhab.binding.airgradient/src/test/java/org/openhab/binding/airgradient/internal/handler/RemoteApiControllerTest.java @@ -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)); + } }