2 * Copyright (c) 2010-2024 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.openweathermap.internal.connection;
15 import static org.eclipse.jetty.http.HttpMethod.GET;
16 import static org.eclipse.jetty.http.HttpStatus.*;
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;
26 import java.util.concurrent.ExecutionException;
27 import java.util.concurrent.TimeUnit;
28 import java.util.concurrent.TimeoutException;
29 import java.util.stream.Collectors;
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;
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;
61 * The {@link OpenWeatherMapConnection} is responsible for handling the connections to OpenWeatherMap API.
63 * @author Christoph Weitkamp - Initial contribution
66 public class OpenWeatherMapConnection {
68 private final Logger logger = LoggerFactory.getLogger(OpenWeatherMapConnection.class);
70 private static final String PROPERTY_MESSAGE = "message";
72 private static final String PNG_CONTENT_TYPE = "image/png";
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";
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";
99 private final OpenWeatherMapAPIHandler handler;
100 private final HttpClient httpClient;
102 private static final ByteArrayFileCache IMAGE_CACHE = new ByteArrayFileCache("org.openhab.binding.openweathermap");
103 private final ExpiringCacheMap<String, String> cache;
105 private final Gson gson = new Gson();
107 public OpenWeatherMapConnection(OpenWeatherMapAPIHandler handler, HttpClient httpClient) {
108 this.handler = handler;
109 this.httpClient = httpClient;
111 OpenWeatherMapAPIConfiguration config = handler.getOpenWeatherMapAPIConfig();
112 cache = new ExpiringCacheMap<>(TimeUnit.MINUTES.toMillis(config.refreshInterval));
116 * Requests the current weather data for the given location (see https://openweathermap.org/current).
118 * @param location location represented as {@link PointType}
119 * @return the current weather data
120 * @throws JsonSyntaxException
121 * @throws CommunicationException
122 * @throws ConfigurationException
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);
133 * Requests the hourly forecast data for the given location (see https://openweathermap.org/forecast5).
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
142 public synchronized @Nullable OpenWeatherMapJsonHourlyForecastData getHourlyForecastData(
143 @Nullable PointType location, int count)
144 throws JsonSyntaxException, CommunicationException, ConfigurationException {
146 throw new ConfigurationException("@text/offline.conf-error-not-supported-number-of-hours");
149 Map<String, String> params = getRequestParams(handler.getOpenWeatherMapAPIConfig(), location);
150 params.put(PARAM_FORECAST_CNT, Integer.toString(count));
152 return gson.fromJson(getResponseFromCache(buildURL(THREE_HOUR_FORECAST_URL, params)),
153 OpenWeatherMapJsonHourlyForecastData.class);
157 * Requests the daily forecast data for the given location (see https://openweathermap.org/forecast16).
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
166 public synchronized @Nullable OpenWeatherMapJsonDailyForecastData getDailyForecastData(@Nullable PointType location,
167 int count) throws JsonSyntaxException, CommunicationException, ConfigurationException {
169 throw new ConfigurationException("@text/offline.conf-error-not-supported-number-of-days");
172 Map<String, String> params = getRequestParams(handler.getOpenWeatherMapAPIConfig(), location);
173 params.put(PARAM_FORECAST_CNT, Integer.toString(count));
175 return gson.fromJson(getResponseFromCache(buildURL(DAILY_FORECAST_URL, params)),
176 OpenWeatherMapJsonDailyForecastData.class);
180 * Requests the Air Pollution data for the given location (see https://openweathermap.org/api/air-pollution).
182 * @param location location represented as {@link PointType}
183 * @return the Air Pollution data
184 * @throws JsonSyntaxException
185 * @throws CommunicationException
186 * @throws ConfigurationException
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);
197 * Requests the Air Pollution forecast data for the given location (see
198 * https://openweathermap.org/api/air-pollution).
200 * @param location location represented as {@link PointType}
201 * @return the Air Pollution forecast data
202 * @throws JsonSyntaxException
203 * @throws CommunicationException
204 * @throws ConfigurationException
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);
215 * Downloads the icon for the given icon id (see https://openweathermap.org/weather-conditions).
217 * @param iconId the id of the icon
218 * @return the weather icon as {@link RawType}
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.");
225 return downloadWeatherIconFromCache(String.format(ICON_URL, iconId));
228 private static @Nullable RawType downloadWeatherIconFromCache(String url) {
229 if (IMAGE_CACHE.containsKey(url)) {
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);
237 RawType image = downloadWeatherIcon(url);
239 IMAGE_CACHE.put(url, image.getBytes());
246 private static @Nullable RawType downloadWeatherIcon(String url) {
247 return HttpUtil.downloadImage(url);
251 * Get Weather data from the One Call API for the given location. See https://openweathermap.org/api/one-call-api
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
259 * @throws JsonSyntaxException
260 * @throws CommunicationException
261 * @throws ConfigurationException
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");
272 exclude.add("hourly");
275 exclude.add("daily");
278 exclude.add("alerts");
280 logger.debug("Exclude: '{}'", exclude);
281 if (!exclude.isEmpty()) {
282 params.put(PARAM_EXCLUDE, exclude.stream().collect(Collectors.joining(",")));
284 return gson.fromJson(getResponseFromCache(buildURL(buildOneCallURL(), params)),
285 OpenWeatherMapOneCallAPIData.class);
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.
293 * @param location location represented as {@link PointType}
294 * @param days number of days in the past, relative to the current time.
296 * @throws JsonSyntaxException
297 * @throws CommunicationException
298 * @throws ConfigurationException
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);
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");
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");
321 params.put(PARAM_APPID, apikey);
323 // Units format (see https://openweathermap.org/current#data)
324 params.put(PARAM_UNITS, "metric");
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());
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());
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 + "?", ""));
343 private String buildOneCallURL() {
344 var config = handler.getOpenWeatherMapAPIConfig();
345 return ONECALL_URL + "/" + config.apiVersion + "/" + ONECALL_DATA_SUFFIX_URL;
348 private String buildOneCallHistoryURL() {
349 var config = handler.getOpenWeatherMapAPIConfig();
350 return ONECALL_URL + "/" + config.apiVersion + "/" + ONECALL_HISTORY_SUFFIX_URL;
353 private String encodeParam(@Nullable String value) {
354 return value == null ? "" : URLEncoder.encode(value, StandardCharsets.UTF_8);
357 private @Nullable String getResponseFromCache(String url) {
358 return cache.putIfAbsentAndGet(url, () -> getResponse(url));
361 private String getResponse(String url) {
363 if (logger.isTraceEnabled()) {
364 logger.trace("OpenWeatherMap request: URL = '{}'", uglifyApikey(url));
366 ContentResponse contentResponse = httpClient.newRequest(url).method(GET).timeout(10, TimeUnit.SECONDS)
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) {
375 case BAD_REQUEST_400:
376 case UNAUTHORIZED_401:
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)
384 errorMessage = getErrorMessage(content);
385 logger.debug("OpenWeatherMap server responded with status code {}: {}", httpStatus, errorMessage);
386 throw new CommunicationException(errorMessage);
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());
395 throw new CommunicationException(
396 errorMessage == null ? "@text/offline.communication-error" : errorMessage, e.getCause());
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,
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,
412 private String uglifyApikey(String url) {
413 return url.replaceAll("(appid=)+\\w+", "appid=*****");
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();