2 * Copyright (c) 2010-2023 Contributors to the openHAB project
4 * See the NOTICE file(s) distributed with this work for additional
7 * This program and the accompanying materials are made available under the
8 * terms of the Eclipse Public License 2.0 which is available at
9 * http://www.eclipse.org/legal/epl-2.0
11 * SPDX-License-Identifier: EPL-2.0
13 package org.openhab.binding.airquality.internal.handler;
15 import static org.openhab.binding.airquality.internal.AirQualityBindingConstants.*;
16 import static org.openhab.core.library.unit.MetricPrefix.*;
18 import java.io.IOException;
19 import java.io.InputStream;
20 import java.util.ArrayList;
21 import java.util.HashMap;
22 import java.util.List;
24 import java.util.Optional;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
27 import java.util.stream.Stream;
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.openhab.binding.airquality.internal.AirQualityException;
32 import org.openhab.binding.airquality.internal.api.ApiBridge;
33 import org.openhab.binding.airquality.internal.api.Appreciation;
34 import org.openhab.binding.airquality.internal.api.Index;
35 import org.openhab.binding.airquality.internal.api.Pollutant;
36 import org.openhab.binding.airquality.internal.api.Pollutant.SensitiveGroup;
37 import org.openhab.binding.airquality.internal.api.dto.AirQualityData;
38 import org.openhab.binding.airquality.internal.config.AirQualityConfiguration;
39 import org.openhab.binding.airquality.internal.config.SensitiveGroupConfiguration;
40 import org.openhab.core.config.core.Configuration;
41 import org.openhab.core.i18n.LocationProvider;
42 import org.openhab.core.i18n.TimeZoneProvider;
43 import org.openhab.core.library.types.DateTimeType;
44 import org.openhab.core.library.types.DecimalType;
45 import org.openhab.core.library.types.OnOffType;
46 import org.openhab.core.library.types.PointType;
47 import org.openhab.core.library.types.QuantityType;
48 import org.openhab.core.library.types.RawType;
49 import org.openhab.core.library.types.StringType;
50 import org.openhab.core.library.unit.SIUnits;
51 import org.openhab.core.library.unit.Units;
52 import org.openhab.core.thing.Bridge;
53 import org.openhab.core.thing.Channel;
54 import org.openhab.core.thing.ChannelUID;
55 import org.openhab.core.thing.Thing;
56 import org.openhab.core.thing.ThingStatus;
57 import org.openhab.core.thing.ThingStatusDetail;
58 import org.openhab.core.thing.binding.BaseThingHandler;
59 import org.openhab.core.thing.binding.BridgeHandler;
60 import org.openhab.core.thing.binding.builder.ThingBuilder;
61 import org.openhab.core.thing.type.ChannelTypeUID;
62 import org.openhab.core.types.Command;
63 import org.openhab.core.types.RefreshType;
64 import org.openhab.core.types.State;
65 import org.openhab.core.types.UnDefType;
66 import org.slf4j.Logger;
67 import org.slf4j.LoggerFactory;
70 * The {@link AirQualityStationHandler} is responsible for handling commands, which are
71 * sent to one of the channels.
73 * @author Kuba Wolanin - Initial contribution
74 * @author Ćukasz Dywicki - Initial contribution
77 public class AirQualityStationHandler extends BaseThingHandler {
78 private final @NonNullByDefault({}) ClassLoader classLoader = AirQualityStationHandler.class.getClassLoader();
79 private final Logger logger = LoggerFactory.getLogger(AirQualityStationHandler.class);
80 private final TimeZoneProvider timeZoneProvider;
81 private final LocationProvider locationProvider;
83 private @Nullable ScheduledFuture<?> refreshJob;
85 public AirQualityStationHandler(Thing thing, TimeZoneProvider timeZoneProvider, LocationProvider locationProvider) {
87 this.timeZoneProvider = timeZoneProvider;
88 this.locationProvider = locationProvider;
92 public void initialize() {
93 logger.debug("Initializing Air Quality handler.");
95 if (thing.getProperties().isEmpty()) {
99 AirQualityConfiguration config = getConfigAs(AirQualityConfiguration.class);
103 refreshJob = scheduler.scheduleWithFixedDelay(this::updateAndPublishData, 0, config.refresh,
105 } catch (AirQualityException e) {
106 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, e.getMessage());
110 private void discoverAttributes() {
111 getAirQualityData().ifPresent(data -> {
112 // Update thing properties
113 Map<String, String> properties = new HashMap<>();
114 properties.put(ATTRIBUTIONS, data.getAttributions());
115 PointType serverLocation = locationProvider.getLocation();
116 if (serverLocation != null) {
117 PointType stationLocation = new PointType(data.getCity().getGeo());
118 double distance = serverLocation.distanceFrom(stationLocation).doubleValue();
119 properties.put(DISTANCE, new QuantityType<>(distance / 1000, KILO(SIUnits.METRE)).toString());
122 // Search and remove missing pollutant channels
123 List<Channel> channels = new ArrayList<>(getThing().getChannels());
124 Stream.of(Pollutant.values()).forEach(pollutant -> {
125 String groupName = pollutant.name().toLowerCase();
126 double value = data.getIaqiValue(pollutant);
127 channels.removeIf(channel -> value == -1 && groupName.equals(channel.getUID().getGroupId()));
130 // Update thing configuration
131 Configuration config = editConfiguration();
132 config.put(AirQualityConfiguration.STATION_ID, data.getStationId());
134 ThingBuilder thingBuilder = editThing();
135 thingBuilder.withChannels(channels).withConfiguration(config).withProperties(properties)
136 .withLocation(data.getCity().getName());
137 updateThing(thingBuilder.build());
141 private void updateAndPublishData() {
142 getAirQualityData().ifPresent(data -> {
143 getThing().getChannels().stream().filter(channel -> isLinked(channel.getUID().getId())).forEach(channel -> {
145 ChannelUID channelUID = channel.getUID();
146 ChannelTypeUID channelTypeUID = channel.getChannelTypeUID();
147 if (channelTypeUID != null && SENSITIVE.equals(channelTypeUID.getId().toString())) {
148 SensitiveGroupConfiguration configuration = channel.getConfiguration()
149 .as(SensitiveGroupConfiguration.class);
150 state = getSensitive(configuration.asSensitiveGroup(), data);
152 state = getValue(channelUID.getIdWithoutGroup(), channelUID.getGroupId(), data);
154 updateState(channelUID, state);
160 public void dispose() {
161 logger.debug("Disposing the Air Quality handler.");
165 private void freeRefreshJob() {
166 ScheduledFuture<?> job = this.refreshJob;
167 if (job != null && !job.isCancelled()) {
174 public void handleCommand(ChannelUID channelUID, Command command) {
175 if (command instanceof RefreshType) {
176 updateAndPublishData();
179 logger.debug("The Air Quality binding is read-only and can not handle command {}", command);
183 * Request new air quality data to the aqicn.org service
185 * @return an optional air quality data object mapping the JSON response
187 private Optional<AirQualityData> getAirQualityData() {
188 AirQualityData result = null;
189 ApiBridge apiBridge = getApiBridge();
190 if (apiBridge != null) {
191 AirQualityConfiguration config = getConfigAs(AirQualityConfiguration.class);
193 result = apiBridge.getData(config.stationId, config.location, 0);
194 updateStatus(ThingStatus.ONLINE);
195 } catch (AirQualityException e) {
196 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, e.getMessage());
199 return Optional.ofNullable(result);
202 private @Nullable ApiBridge getApiBridge() {
203 Bridge bridge = this.getBridge();
204 if (bridge != null && bridge.getStatus() == ThingStatus.ONLINE) {
205 BridgeHandler handler = bridge.getHandler();
206 if (handler instanceof AirQualityBridgeHandler) {
207 return ((AirQualityBridgeHandler) handler).getApiBridge();
209 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "@text/incorrect-bridge");
212 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.BRIDGE_OFFLINE);
217 private State indexedValue(String channelId, double idx, @Nullable Pollutant pollutant) {
218 Index index = Index.find(idx);
222 return new DecimalType(idx);
224 return pollutant != null ? pollutant.toQuantity(idx) : UnDefType.UNDEF;
226 byte[] bytes = getResource(String.format("picto/%s.svg", index.getCategory().name().toLowerCase()));
227 return bytes != null ? new RawType(bytes, "image/svg+xml") : UnDefType.UNDEF;
229 return index.getCategory().getColor();
231 return new DecimalType(index.getCategory().ordinal());
234 return UnDefType.UNDEF;
237 private State getSensitive(@Nullable SensitiveGroup sensitiveGroup, AirQualityData data) {
238 if (sensitiveGroup != null) {
239 int threshHold = Appreciation.UNHEALTHY_FSG.ordinal();
240 for (Pollutant pollutant : Pollutant.values()) {
241 Index index = Index.find(data.getIaqiValue(pollutant));
242 if (index != null && pollutant.sensitiveGroups.contains(sensitiveGroup)
243 && index.getCategory().ordinal() >= threshHold) {
247 return OnOffType.OFF;
249 return UnDefType.NULL;
252 private State getValue(String channelId, @Nullable String groupId, AirQualityData data) {
255 double temp = data.getIaqiValue("t");
256 return temp != -1 ? new QuantityType<>(temp, SIUnits.CELSIUS) : UnDefType.UNDEF;
258 double press = data.getIaqiValue("p");
259 return press != -1 ? new QuantityType<>(press, HECTO(SIUnits.PASCAL)) : UnDefType.UNDEF;
261 double hum = data.getIaqiValue("h");
262 return hum != -1 ? new QuantityType<>(hum, Units.PERCENT) : UnDefType.UNDEF;
264 return new DateTimeType(
265 data.getTime().getObservationTime().withZoneSameLocal(timeZoneProvider.getTimeZone()));
267 return new StringType(data.getDominentPol());
269 double dp = data.getIaqiValue("dew");
270 return dp != -1 ? new QuantityType<>(dp, SIUnits.CELSIUS) : UnDefType.UNDEF;
272 double w = data.getIaqiValue("w");
273 return w != -1 ? new QuantityType<>(w, Units.METRE_PER_SECOND) : UnDefType.UNDEF;
275 if (groupId != null) {
277 Pollutant pollutant = null;
278 if (AQI.equals(groupId)) {
281 pollutant = Pollutant.valueOf(groupId.toUpperCase());
282 idx = data.getIaqiValue(pollutant);
284 return indexedValue(channelId, idx, pollutant);
286 return UnDefType.UNDEF;
290 private byte @Nullable [] getResource(String iconPath) {
291 try (InputStream stream = classLoader.getResourceAsStream(iconPath)) {
292 return stream != null ? stream.readAllBytes() : null;
293 } catch (IOException e) {
294 logger.warn("Unable to load ressource '{}' : {}", iconPath, e.getMessage());