]> git.basschouten.com Git - openhab-addons.git/blob
8efc1e8d5a47eca5030788b4fbec2212dbc610ae
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.openweathermap.internal.connection;
14
15 import static org.eclipse.jetty.http.HttpMethod.GET;
16 import static org.eclipse.jetty.http.HttpStatus.*;
17
18 import java.net.URLEncoder;
19 import java.nio.charset.StandardCharsets;
20 import java.time.ZoneId;
21 import java.time.ZonedDateTime;
22 import java.util.ArrayList;
23 import java.util.HashMap;
24 import java.util.List;
25 import java.util.Map;
26 import java.util.concurrent.ExecutionException;
27 import java.util.concurrent.TimeUnit;
28 import java.util.concurrent.TimeoutException;
29 import java.util.stream.Collectors;
30
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.eclipse.jetty.client.HttpClient;
34 import org.eclipse.jetty.client.HttpResponseException;
35 import org.eclipse.jetty.client.api.ContentResponse;
36 import org.openhab.binding.openweathermap.internal.config.OpenWeatherMapAPIConfiguration;
37 import org.openhab.binding.openweathermap.internal.dto.OpenWeatherMapJsonAirPollutionData;
38 import org.openhab.binding.openweathermap.internal.dto.OpenWeatherMapJsonDailyForecastData;
39 import org.openhab.binding.openweathermap.internal.dto.OpenWeatherMapJsonHourlyForecastData;
40 import org.openhab.binding.openweathermap.internal.dto.OpenWeatherMapJsonWeatherData;
41 import org.openhab.binding.openweathermap.internal.dto.OpenWeatherMapOneCallAPIData;
42 import org.openhab.binding.openweathermap.internal.dto.OpenWeatherMapOneCallHistAPIData;
43 import org.openhab.binding.openweathermap.internal.handler.OpenWeatherMapAPIHandler;
44 import org.openhab.core.cache.ByteArrayFileCache;
45 import org.openhab.core.cache.ExpiringCacheMap;
46 import org.openhab.core.i18n.CommunicationException;
47 import org.openhab.core.i18n.ConfigurationException;
48 import org.openhab.core.io.net.http.HttpUtil;
49 import org.openhab.core.library.types.PointType;
50 import org.openhab.core.library.types.RawType;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
53
54 import com.google.gson.Gson;
55 import com.google.gson.JsonElement;
56 import com.google.gson.JsonObject;
57 import com.google.gson.JsonParser;
58 import com.google.gson.JsonSyntaxException;
59
60 /**
61  * The {@link OpenWeatherMapConnection} is responsible for handling the connections to OpenWeatherMap API.
62  *
63  * @author Christoph Weitkamp - Initial contribution
64  */
65 @NonNullByDefault
66 public class OpenWeatherMapConnection {
67
68     private final Logger logger = LoggerFactory.getLogger(OpenWeatherMapConnection.class);
69
70     private static final String PROPERTY_MESSAGE = "message";
71
72     private static final String PNG_CONTENT_TYPE = "image/png";
73
74     private static final String PARAM_APPID = "appid";
75     private static final String PARAM_UNITS = "units";
76     private static final String PARAM_LAT = "lat";
77     private static final String PARAM_LON = "lon";
78     private static final String PARAM_LANG = "lang";
79     private static final String PARAM_FORECAST_CNT = "cnt";
80     private static final String PARAM_HISTORY_DATE = "dt";
81     private static final String PARAM_EXCLUDE = "exclude";
82
83     // Current weather data (see https://openweathermap.org/current)
84     private static final String WEATHER_URL = "https://api.openweathermap.org/data/2.5/weather";
85     // 5 day / 3 hour forecast (see https://openweathermap.org/forecast5)
86     private static final String THREE_HOUR_FORECAST_URL = "https://api.openweathermap.org/data/2.5/forecast";
87     // 16 day / daily forecast (see https://openweathermap.org/forecast16)
88     private static final String DAILY_FORECAST_URL = "https://api.openweathermap.org/data/2.5/forecast/daily";
89     // Air Pollution (see https://openweathermap.org/api/air-pollution)
90     private static final String AIR_POLLUTION_URL = "https://api.openweathermap.org/data/2.5/air_pollution";
91     private static final String AIR_POLLUTION_FORECAST_URL = "https://api.openweathermap.org/data/2.5/air_pollution/forecast";
92     // Weather icons (see https://openweathermap.org/weather-conditions)
93     private static final String ICON_URL = "https://openweathermap.org/img/w/%s.png";
94     // One Call API (see https://openweathermap.org/api/one-call-api )
95     private static final String ONECALL_URL = "https://api.openweathermap.org/data";
96     private static final String ONECALL_DATA_SUFFIX_URL = "onecall";
97     private static final String ONECALL_HISTORY_SUFFIX_URL = "onecall/timemachine";
98
99     private final OpenWeatherMapAPIHandler handler;
100     private final HttpClient httpClient;
101
102     private static final ByteArrayFileCache IMAGE_CACHE = new ByteArrayFileCache("org.openhab.binding.openweathermap");
103     private final ExpiringCacheMap<String, String> cache;
104
105     private final Gson gson = new Gson();
106
107     public OpenWeatherMapConnection(OpenWeatherMapAPIHandler handler, HttpClient httpClient) {
108         this.handler = handler;
109         this.httpClient = httpClient;
110
111         OpenWeatherMapAPIConfiguration config = handler.getOpenWeatherMapAPIConfig();
112         cache = new ExpiringCacheMap<>(TimeUnit.MINUTES.toMillis(config.refreshInterval));
113     }
114
115     /**
116      * Requests the current weather data for the given location (see https://openweathermap.org/current).
117      *
118      * @param location location represented as {@link PointType}
119      * @return the current weather data
120      * @throws JsonSyntaxException
121      * @throws CommunicationException
122      * @throws ConfigurationException
123      */
124     public synchronized @Nullable OpenWeatherMapJsonWeatherData getWeatherData(@Nullable PointType location)
125             throws JsonSyntaxException, CommunicationException, ConfigurationException {
126         return gson.fromJson(
127                 getResponseFromCache(
128                         buildURL(WEATHER_URL, getRequestParams(handler.getOpenWeatherMapAPIConfig(), location))),
129                 OpenWeatherMapJsonWeatherData.class);
130     }
131
132     /**
133      * Requests the hourly forecast data for the given location (see https://openweathermap.org/forecast5).
134      *
135      * @param location location represented as {@link PointType}
136      * @param count number of hours
137      * @return the hourly forecast data
138      * @throws JsonSyntaxException
139      * @throws CommunicationException
140      * @throws ConfigurationException
141      */
142     public synchronized @Nullable OpenWeatherMapJsonHourlyForecastData getHourlyForecastData(
143             @Nullable PointType location, int count)
144             throws JsonSyntaxException, CommunicationException, ConfigurationException {
145         if (count <= 0) {
146             throw new ConfigurationException("@text/offline.conf-error-not-supported-number-of-hours");
147         }
148
149         Map<String, String> params = getRequestParams(handler.getOpenWeatherMapAPIConfig(), location);
150         params.put(PARAM_FORECAST_CNT, Integer.toString(count));
151
152         return gson.fromJson(getResponseFromCache(buildURL(THREE_HOUR_FORECAST_URL, params)),
153                 OpenWeatherMapJsonHourlyForecastData.class);
154     }
155
156     /**
157      * Requests the daily forecast data for the given location (see https://openweathermap.org/forecast16).
158      *
159      * @param location location represented as {@link PointType}
160      * @param count number of days
161      * @return the daily forecast data
162      * @throws JsonSyntaxException
163      * @throws CommunicationException
164      * @throws ConfigurationException
165      */
166     public synchronized @Nullable OpenWeatherMapJsonDailyForecastData getDailyForecastData(@Nullable PointType location,
167             int count) throws JsonSyntaxException, CommunicationException, ConfigurationException {
168         if (count <= 0) {
169             throw new ConfigurationException("@text/offline.conf-error-not-supported-number-of-days");
170         }
171
172         Map<String, String> params = getRequestParams(handler.getOpenWeatherMapAPIConfig(), location);
173         params.put(PARAM_FORECAST_CNT, Integer.toString(count));
174
175         return gson.fromJson(getResponseFromCache(buildURL(DAILY_FORECAST_URL, params)),
176                 OpenWeatherMapJsonDailyForecastData.class);
177     }
178
179     /**
180      * Requests the Air Pollution data for the given location (see https://openweathermap.org/api/air-pollution).
181      *
182      * @param location location represented as {@link PointType}
183      * @return the Air Pollution data
184      * @throws JsonSyntaxException
185      * @throws CommunicationException
186      * @throws ConfigurationException
187      */
188     public synchronized @Nullable OpenWeatherMapJsonAirPollutionData getAirPollutionData(@Nullable PointType location)
189             throws JsonSyntaxException, CommunicationException, ConfigurationException {
190         return gson.fromJson(
191                 getResponseFromCache(
192                         buildURL(AIR_POLLUTION_URL, getRequestParams(handler.getOpenWeatherMapAPIConfig(), location))),
193                 OpenWeatherMapJsonAirPollutionData.class);
194     }
195
196     /**
197      * Requests the Air Pollution forecast data for the given location (see
198      * https://openweathermap.org/api/air-pollution).
199      *
200      * @param location location represented as {@link PointType}
201      * @return the Air Pollution forecast data
202      * @throws JsonSyntaxException
203      * @throws CommunicationException
204      * @throws ConfigurationException
205      */
206     public synchronized @Nullable OpenWeatherMapJsonAirPollutionData getAirPollutionForecastData(
207             @Nullable PointType location) throws JsonSyntaxException, CommunicationException, ConfigurationException {
208         return gson.fromJson(
209                 getResponseFromCache(buildURL(AIR_POLLUTION_FORECAST_URL,
210                         getRequestParams(handler.getOpenWeatherMapAPIConfig(), location))),
211                 OpenWeatherMapJsonAirPollutionData.class);
212     }
213
214     /**
215      * Downloads the icon for the given icon id (see https://openweathermap.org/weather-conditions).
216      *
217      * @param iconId the id of the icon
218      * @return the weather icon as {@link RawType}
219      */
220     public static @Nullable RawType getWeatherIcon(String iconId) {
221         if (iconId.isEmpty()) {
222             throw new IllegalArgumentException("Cannot download weather icon as icon id is null.");
223         }
224
225         return downloadWeatherIconFromCache(String.format(ICON_URL, iconId));
226     }
227
228     private static @Nullable RawType downloadWeatherIconFromCache(String url) {
229         if (IMAGE_CACHE.containsKey(url)) {
230             try {
231                 return new RawType(IMAGE_CACHE.get(url), PNG_CONTENT_TYPE);
232             } catch (Exception e) {
233                 LoggerFactory.getLogger(OpenWeatherMapConnection.class)
234                         .trace("Failed to download the content of URL '{}'", url, e);
235             }
236         } else {
237             RawType image = downloadWeatherIcon(url);
238             if (image != null) {
239                 IMAGE_CACHE.put(url, image.getBytes());
240                 return image;
241             }
242         }
243         return null;
244     }
245
246     private static @Nullable RawType downloadWeatherIcon(String url) {
247         return HttpUtil.downloadImage(url);
248     }
249
250     /**
251      * Get Weather data from the One Call API for the given location. See https://openweathermap.org/api/one-call-api
252      * for details.
253      *
254      * @param location location represented as {@link PointType}
255      * @param excludeMinutely if true, will not fetch minutely forecast data from the server
256      * @param excludeHourly if true, will not fetch hourly forecast data from the server
257      * @param excludeDaily if true, will not fetch hourly forecast data from the server
258      * @return
259      * @throws JsonSyntaxException
260      * @throws CommunicationException
261      * @throws ConfigurationException
262      */
263     public synchronized @Nullable OpenWeatherMapOneCallAPIData getOneCallAPIData(@Nullable PointType location,
264             boolean excludeMinutely, boolean excludeHourly, boolean excludeDaily, boolean excludeAlerts)
265             throws JsonSyntaxException, CommunicationException, ConfigurationException {
266         Map<String, String> params = getRequestParams(handler.getOpenWeatherMapAPIConfig(), location);
267         List<String> exclude = new ArrayList<>();
268         if (excludeMinutely) {
269             exclude.add("minutely");
270         }
271         if (excludeHourly) {
272             exclude.add("hourly");
273         }
274         if (excludeDaily) {
275             exclude.add("daily");
276         }
277         if (excludeAlerts) {
278             exclude.add("alerts");
279         }
280         logger.debug("Exclude: '{}'", exclude);
281         if (!exclude.isEmpty()) {
282             params.put(PARAM_EXCLUDE, exclude.stream().collect(Collectors.joining(",")));
283         }
284         return gson.fromJson(getResponseFromCache(buildURL(buildOneCallURL(), params)),
285                 OpenWeatherMapOneCallAPIData.class);
286     }
287
288     /**
289      * Get the historical weather data from the One Call API for the given location and the given number of days in the
290      * past. As of now, OpenWeatherMap supports this function for up to 5 days in the past. However, this may change in
291      * the future, so we don't enforce this limit here. See https://openweathermap.org/api/one-call-api for details.
292      *
293      * @param location location represented as {@link PointType}
294      * @param days number of days in the past, relative to the current time.
295      * @return
296      * @throws JsonSyntaxException
297      * @throws CommunicationException
298      * @throws ConfigurationException
299      */
300     public synchronized @Nullable OpenWeatherMapOneCallHistAPIData getOneCallHistAPIData(@Nullable PointType location,
301             int days) throws JsonSyntaxException, CommunicationException, ConfigurationException {
302         Map<String, String> params = getRequestParams(handler.getOpenWeatherMapAPIConfig(), location);
303         // the API requests the history as timestamp in Unix time format.
304         params.put(PARAM_HISTORY_DATE,
305                 Long.toString(ZonedDateTime.now(ZoneId.of("UTC")).minusDays(days).toEpochSecond()));
306         return gson.fromJson(getResponseFromCache(buildURL(buildOneCallHistoryURL(), params)),
307                 OpenWeatherMapOneCallHistAPIData.class);
308     }
309
310     private Map<String, String> getRequestParams(OpenWeatherMapAPIConfiguration config, @Nullable PointType location) {
311         if (location == null) {
312             throw new ConfigurationException("@text/offline.conf-error-missing-location");
313         }
314
315         Map<String, String> params = new HashMap<>();
316         // API key (see https://openweathermap.org/appid)
317         String apikey = config.apikey;
318         if (apikey == null || (apikey = apikey.trim()).isEmpty()) {
319             throw new ConfigurationException("@text/offline.conf-error-missing-apikey");
320         }
321         params.put(PARAM_APPID, apikey);
322
323         // Units format (see https://openweathermap.org/current#data)
324         params.put(PARAM_UNITS, "metric");
325
326         // By geographic coordinates (see https://openweathermap.org/current#geo)
327         params.put(PARAM_LAT, location.getLatitude().toString());
328         params.put(PARAM_LON, location.getLongitude().toString());
329
330         // Multilingual support (see https://openweathermap.org/current#multi)
331         String language = config.language;
332         if (language != null && !(language = language.trim()).isEmpty()) {
333             params.put(PARAM_LANG, language.toLowerCase());
334         }
335         return params;
336     }
337
338     private String buildURL(String url, Map<String, String> requestParams) {
339         return requestParams.keySet().stream().map(key -> key + "=" + encodeParam(requestParams.get(key)))
340                 .collect(Collectors.joining("&", url + "?", ""));
341     }
342
343     private String buildOneCallURL() {
344         var config = handler.getOpenWeatherMapAPIConfig();
345         return ONECALL_URL + "/" + config.apiVersion + "/" + ONECALL_DATA_SUFFIX_URL;
346     }
347
348     private String buildOneCallHistoryURL() {
349         var config = handler.getOpenWeatherMapAPIConfig();
350         return ONECALL_URL + "/" + config.apiVersion + "/" + ONECALL_HISTORY_SUFFIX_URL;
351     }
352
353     private String encodeParam(@Nullable String value) {
354         return value == null ? "" : URLEncoder.encode(value, StandardCharsets.UTF_8);
355     }
356
357     private @Nullable String getResponseFromCache(String url) {
358         return cache.putIfAbsentAndGet(url, () -> getResponse(url));
359     }
360
361     private String getResponse(String url) {
362         try {
363             if (logger.isTraceEnabled()) {
364                 logger.trace("OpenWeatherMap request: URL = '{}'", uglifyApikey(url));
365             }
366             ContentResponse contentResponse = httpClient.newRequest(url).method(GET).timeout(10, TimeUnit.SECONDS)
367                     .send();
368             int httpStatus = contentResponse.getStatus();
369             String content = contentResponse.getContentAsString();
370             String errorMessage = "";
371             logger.trace("OpenWeatherMap response: status = {}, content = '{}'", httpStatus, content);
372             switch (httpStatus) {
373                 case OK_200:
374                     return content;
375                 case BAD_REQUEST_400:
376                 case UNAUTHORIZED_401:
377                 case NOT_FOUND_404:
378                     errorMessage = getErrorMessage(content);
379                     logger.debug("OpenWeatherMap server responded with status code {}: {}", httpStatus, errorMessage);
380                     throw new ConfigurationException(errorMessage);
381                 case TOO_MANY_REQUESTS_429:
382                     // TODO disable refresh job temporarily (see https://openweathermap.org/appid#Accesslimitation)
383                 default:
384                     errorMessage = getErrorMessage(content);
385                     logger.debug("OpenWeatherMap server responded with status code {}: {}", httpStatus, errorMessage);
386                     throw new CommunicationException(errorMessage);
387             }
388         } catch (ExecutionException e) {
389             String errorMessage = e.getMessage();
390             logger.debug("ExecutionException occurred during execution: {}", errorMessage, e);
391             if (e.getCause() instanceof HttpResponseException) {
392                 logger.debug("OpenWeatherMap server responded with status code {}: Invalid API key.", UNAUTHORIZED_401);
393                 throw new ConfigurationException("@text/offline.conf-error-invalid-apikey", e.getCause());
394             } else {
395                 throw new CommunicationException(
396                         errorMessage == null ? "@text/offline.communication-error" : errorMessage, e.getCause());
397             }
398         } catch (TimeoutException e) {
399             String errorMessage = e.getMessage();
400             logger.debug("TimeoutException occurred during execution: {}", errorMessage, e);
401             throw new CommunicationException(errorMessage == null ? "@text/offline.communication-error" : errorMessage,
402                     e.getCause());
403         } catch (InterruptedException e) {
404             String errorMessage = e.getMessage();
405             logger.debug("InterruptedException occurred during execution: {}", errorMessage, e);
406             Thread.currentThread().interrupt();
407             throw new CommunicationException(errorMessage == null ? "@text/offline.communication-error" : errorMessage,
408                     e.getCause());
409         }
410     }
411
412     private String uglifyApikey(String url) {
413         return url.replaceAll("(appid=)+\\w+", "appid=*****");
414     }
415
416     private String getErrorMessage(String response) {
417         JsonElement jsonResponse = JsonParser.parseString(response);
418         if (jsonResponse.isJsonObject()) {
419             JsonObject json = jsonResponse.getAsJsonObject();
420             if (json.has(PROPERTY_MESSAGE)) {
421                 return json.get(PROPERTY_MESSAGE).getAsString();
422             }
423         }
424         return response;
425     }
426 }