]> git.basschouten.com Git - openhab-addons.git/blob
3caf969eddc2fa7a258e6ab45ec59213ce6db305
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2021 Contributors to the openHAB project
3  *
4  * See the NOTICE file(s) distributed with this work for additional
5  * information.
6  *
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
10  *
11  * SPDX-License-Identifier: EPL-2.0
12  */
13 package org.openhab.binding.airquality.internal.handler;
14
15 import static org.openhab.binding.airquality.internal.AirQualityBindingConstants.*;
16
17 import java.io.IOException;
18 import java.util.ArrayList;
19 import java.util.List;
20 import java.util.concurrent.ScheduledFuture;
21 import java.util.concurrent.TimeUnit;
22
23 import org.eclipse.jdt.annotation.NonNullByDefault;
24 import org.eclipse.jdt.annotation.Nullable;
25 import org.openhab.binding.airquality.internal.AirQualityConfiguration;
26 import org.openhab.binding.airquality.internal.json.AirQualityJsonData;
27 import org.openhab.binding.airquality.internal.json.AirQualityJsonResponse;
28 import org.openhab.binding.airquality.internal.json.AirQualityJsonResponse.ResponseStatus;
29 import org.openhab.core.i18n.TimeZoneProvider;
30 import org.openhab.core.io.net.http.HttpUtil;
31 import org.openhab.core.library.types.DateTimeType;
32 import org.openhab.core.library.types.DecimalType;
33 import org.openhab.core.library.types.HSBType;
34 import org.openhab.core.library.types.PointType;
35 import org.openhab.core.library.types.QuantityType;
36 import org.openhab.core.library.types.StringType;
37 import org.openhab.core.thing.ChannelUID;
38 import org.openhab.core.thing.Thing;
39 import org.openhab.core.thing.ThingStatus;
40 import org.openhab.core.thing.ThingStatusDetail;
41 import org.openhab.core.thing.binding.BaseThingHandler;
42 import org.openhab.core.types.Command;
43 import org.openhab.core.types.RefreshType;
44 import org.openhab.core.types.State;
45 import org.openhab.core.types.UnDefType;
46 import org.slf4j.Logger;
47 import org.slf4j.LoggerFactory;
48
49 import com.google.gson.Gson;
50 import com.google.gson.JsonSyntaxException;
51
52 /**
53  * The {@link AirQualityHandler} is responsible for handling commands, which are
54  * sent to one of the channels.
55  *
56  * @author Kuba Wolanin - Initial contribution
57  * @author Ćukasz Dywicki - Initial contribution
58  */
59 @NonNullByDefault
60 public class AirQualityHandler extends BaseThingHandler {
61     private static final String URL = "http://api.waqi.info/feed/%QUERY%/?token=%apikey%";
62     private static final int REQUEST_TIMEOUT_MS = (int) TimeUnit.SECONDS.toMillis(30);
63     private final Logger logger = LoggerFactory.getLogger(AirQualityHandler.class);
64     private @Nullable ScheduledFuture<?> refreshJob;
65
66     private final Gson gson;
67
68     private int retryCounter = 0;
69     private final TimeZoneProvider timeZoneProvider;
70
71     public AirQualityHandler(Thing thing, Gson gson, TimeZoneProvider timeZoneProvider) {
72         super(thing);
73         this.gson = gson;
74         this.timeZoneProvider = timeZoneProvider;
75     }
76
77     @Override
78     public void initialize() {
79         logger.debug("Initializing Air Quality handler.");
80
81         AirQualityConfiguration config = getConfigAs(AirQualityConfiguration.class);
82         logger.debug("config apikey = (omitted from logging)");
83         logger.debug("config location = {}", config.location);
84         logger.debug("config stationId = {}", config.stationId);
85         logger.debug("config refresh = {}", config.refresh);
86
87         List<String> errorMsg = new ArrayList<>();
88
89         if (config.apikey.trim().isEmpty()) {
90             errorMsg.add("Parameter 'apikey' is mandatory and must be configured");
91         }
92         if (config.location.trim().isEmpty() && config.stationId == null) {
93             errorMsg.add("Parameter 'location' or 'stationId' is mandatory and must be configured");
94         }
95         if (config.refresh < 30) {
96             errorMsg.add("Parameter 'refresh' must be at least 30 minutes");
97         }
98
99         if (errorMsg.isEmpty()) {
100             ScheduledFuture<?> job = this.refreshJob;
101             if (job == null || job.isCancelled()) {
102                 refreshJob = scheduler.scheduleWithFixedDelay(this::updateAndPublishData, 0, config.refresh,
103                         TimeUnit.MINUTES);
104             }
105         } else {
106             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String.join(", ", errorMsg));
107         }
108     }
109
110     private void updateAndPublishData() {
111         retryCounter = 0;
112         AirQualityJsonData aqiResponse = getAirQualityData();
113         if (aqiResponse != null) {
114             // Update all channels from the updated AQI data
115             getThing().getChannels().stream().filter(channel -> isLinked(channel.getUID().getId())).forEach(channel -> {
116                 String channelId = channel.getUID().getId();
117                 State state = getValue(channelId, aqiResponse);
118                 updateState(channelId, state);
119             });
120         }
121     }
122
123     @Override
124     public void dispose() {
125         logger.debug("Disposing the Air Quality handler.");
126         ScheduledFuture<?> job = this.refreshJob;
127         if (job != null && !job.isCancelled()) {
128             job.cancel(true);
129             refreshJob = null;
130         }
131     }
132
133     @Override
134     public void handleCommand(ChannelUID channelUID, Command command) {
135         if (command instanceof RefreshType) {
136             updateAndPublishData();
137         } else {
138             logger.debug("The Air Quality binding is read-only and can not handle command {}", command);
139         }
140     }
141
142     /**
143      * Build request URL from configuration data
144      *
145      * @return a valid URL for the aqicn.org service
146      */
147     private String buildRequestURL() {
148         AirQualityConfiguration config = getConfigAs(AirQualityConfiguration.class);
149
150         String location = config.location.trim();
151         Integer stationId = config.stationId;
152
153         String geoStr = "geo:" + location.replace(" ", "").replace(",", ";").replace("\"", "").replace("'", "").trim();
154
155         String urlStr = URL.replace("%apikey%", config.apikey.trim());
156
157         return urlStr.replace("%QUERY%", stationId == null ? geoStr : "@" + stationId);
158     }
159
160     /**
161      * Request new air quality data to the aqicn.org service
162      *
163      * @param location geo-coordinates from config
164      * @param stationId station ID from config
165      * @return the air quality data object mapping the JSON response or null in case of error
166      */
167     private @Nullable AirQualityJsonData getAirQualityData() {
168         String errorMsg;
169
170         String urlStr = buildRequestURL();
171         logger.debug("URL = {}", urlStr);
172
173         try {
174             String response = HttpUtil.executeUrl("GET", urlStr, null, null, null, REQUEST_TIMEOUT_MS);
175             logger.debug("aqiResponse = {}", response);
176             AirQualityJsonResponse result = gson.fromJson(response, AirQualityJsonResponse.class);
177             if (result.getStatus() == ResponseStatus.OK) {
178                 AirQualityJsonData data = result.getData();
179                 String attributions = data.getAttributions();
180                 updateStatus(ThingStatus.ONLINE, ThingStatusDetail.NONE, attributions);
181                 return data;
182             } else {
183                 retryCounter++;
184                 if (retryCounter == 1) {
185                     logger.warn("Error in aqicn.org, retrying once");
186                     return getAirQualityData();
187                 }
188                 errorMsg = "Missing data sub-object";
189                 logger.warn("Error in aqicn.org response: {}", errorMsg);
190             }
191         } catch (IOException e) {
192             errorMsg = e.getMessage();
193         } catch (JsonSyntaxException e) {
194             errorMsg = "Configuration is incorrect";
195             logger.warn("Error running aqicn.org request: {}", errorMsg);
196         }
197
198         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.OFFLINE.COMMUNICATION_ERROR, errorMsg);
199         return null;
200     }
201
202     public State getValue(String channelId, AirQualityJsonData aqiResponse) {
203         String[] fields = channelId.split("#");
204
205         switch (fields[0]) {
206             case AQI:
207                 return new DecimalType(aqiResponse.getAqi());
208             case AQIDESCRIPTION:
209                 return getAqiDescription(aqiResponse.getAqi());
210             case PM25:
211             case PM10:
212             case O3:
213             case NO2:
214             case CO:
215             case SO2:
216                 double value = aqiResponse.getIaqiValue(fields[0]);
217                 return value != -1 ? new DecimalType(value) : UnDefType.UNDEF;
218             case TEMPERATURE:
219                 double temp = aqiResponse.getIaqiValue("t");
220                 return temp != -1 ? new QuantityType<>(temp, API_TEMPERATURE_UNIT) : UnDefType.UNDEF;
221             case PRESSURE:
222                 double press = aqiResponse.getIaqiValue("p");
223                 return press != -1 ? new QuantityType<>(press, API_PRESSURE_UNIT) : UnDefType.UNDEF;
224             case HUMIDITY:
225                 double hum = aqiResponse.getIaqiValue("h");
226                 return hum != -1 ? new QuantityType<>(hum, API_HUMIDITY_UNIT) : UnDefType.UNDEF;
227             case LOCATIONNAME:
228                 return new StringType(aqiResponse.getCity().getName());
229             case STATIONID:
230                 return new DecimalType(aqiResponse.getStationId());
231             case STATIONLOCATION:
232                 return new PointType(aqiResponse.getCity().getGeo());
233             case OBSERVATIONTIME:
234                 return new DateTimeType(
235                         aqiResponse.getTime().getObservationTime().withZoneSameLocal(timeZoneProvider.getTimeZone()));
236             case DOMINENTPOL:
237                 return new StringType(aqiResponse.getDominentPol());
238             case AQI_COLOR:
239                 return getAsHSB(aqiResponse.getAqi());
240             default:
241                 return UnDefType.UNDEF;
242         }
243     }
244
245     /**
246      * Interprets the current aqi value within the ranges;
247      * Returns AQI in a human readable format
248      *
249      * @return
250      */
251     public State getAqiDescription(int index) {
252         if (index >= 300) {
253             return HAZARDOUS;
254         } else if (index >= 201) {
255             return VERY_UNHEALTHY;
256         } else if (index >= 151) {
257             return UNHEALTHY;
258         } else if (index >= 101) {
259             return UNHEALTHY_FOR_SENSITIVE;
260         } else if (index >= 51) {
261             return MODERATE;
262         } else if (index > 0) {
263             return GOOD;
264         }
265         return UnDefType.UNDEF;
266     }
267
268     private State getAsHSB(int index) {
269         State state = getAqiDescription(index);
270         if (state == HAZARDOUS) {
271             return HSBType.fromRGB(343, 100, 49);
272         } else if (state == VERY_UNHEALTHY) {
273             return HSBType.fromRGB(280, 100, 60);
274         } else if (state == UNHEALTHY) {
275             return HSBType.fromRGB(345, 100, 80);
276         } else if (state == UNHEALTHY_FOR_SENSITIVE) {
277             return HSBType.fromRGB(30, 80, 100);
278         } else if (state == MODERATE) {
279             return HSBType.fromRGB(50, 80, 100);
280         } else if (state == GOOD) {
281             return HSBType.fromRGB(160, 100, 60);
282         }
283         return UnDefType.UNDEF;
284     }
285 }