]> git.basschouten.com Git - openhab-addons.git/blob
4659d7dbfe7484d928ab8e69372fdb1e67e5fa84
[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.weathercompany.internal.handler;
14
15 import static org.openhab.binding.weathercompany.internal.WeatherCompanyBindingConstants.*;
16
17 import java.awt.image.BufferedImage;
18 import java.io.ByteArrayOutputStream;
19 import java.io.File;
20 import java.io.IOException;
21 import java.io.InputStream;
22 import java.net.URL;
23 import java.util.Objects;
24 import java.util.concurrent.Future;
25 import java.util.concurrent.TimeUnit;
26
27 import javax.imageio.ImageIO;
28
29 import org.eclipse.jdt.annotation.NonNullByDefault;
30 import org.eclipse.jdt.annotation.Nullable;
31 import org.eclipse.jetty.client.HttpClient;
32 import org.openhab.binding.weathercompany.internal.config.WeatherCompanyForecastConfig;
33 import org.openhab.binding.weathercompany.internal.model.DayPartDTO;
34 import org.openhab.binding.weathercompany.internal.model.ForecastDTO;
35 import org.openhab.core.i18n.LocaleProvider;
36 import org.openhab.core.i18n.TimeZoneProvider;
37 import org.openhab.core.i18n.UnitProvider;
38 import org.openhab.core.library.types.RawType;
39 import org.openhab.core.library.unit.Units;
40 import org.openhab.core.thing.ChannelUID;
41 import org.openhab.core.thing.Thing;
42 import org.openhab.core.thing.ThingStatus;
43 import org.openhab.core.thing.ThingStatusDetail;
44 import org.openhab.core.types.Command;
45 import org.openhab.core.types.RefreshType;
46 import org.openhab.core.types.State;
47 import org.openhab.core.types.UnDefType;
48 import org.osgi.framework.FrameworkUtil;
49 import org.slf4j.Logger;
50 import org.slf4j.LoggerFactory;
51
52 import com.google.gson.JsonSyntaxException;
53
54 /**
55  * The {@link WeatherCompanyForecastHandler} is responsible for pulling weather forecast
56  * information from the Weather Company API.
57  *
58  * API documentation is located here
59  * - https://docs.google.com/document/d/1eKCnKXI9xnoMGRRzOL1xPCBihNV2rOet08qpE_gArAY/edit
60  *
61  * @author Mark Hilbush - Initial contribution
62  */
63 @NonNullByDefault
64 public class WeatherCompanyForecastHandler extends WeatherCompanyAbstractHandler {
65     private static final String BASE_FORECAST_URL = "https://api.weather.com/v3/wx/forecast/daily/5day";
66
67     private final Logger logger = LoggerFactory.getLogger(WeatherCompanyForecastHandler.class);
68
69     private final LocaleProvider localeProvider;
70
71     private int refreshIntervalSeconds;
72     private String locationQueryString = "";
73     private String languageQueryString = "";
74
75     private @Nullable Future<?> refreshForecastJob;
76
77     private final Runnable refreshRunnable = new Runnable() {
78         @Override
79         public void run() {
80             refreshForecast();
81         }
82     };
83
84     public WeatherCompanyForecastHandler(Thing thing, TimeZoneProvider timeZoneProvider, HttpClient httpClient,
85             UnitProvider unitProvider, LocaleProvider localeProvider) {
86         super(thing, timeZoneProvider, httpClient, unitProvider);
87         this.localeProvider = localeProvider;
88     }
89
90     @Override
91     public void initialize() {
92         logger.debug("Forecast handler initializing with configuration: {}",
93                 getConfigAs(WeatherCompanyForecastConfig.class).toString());
94
95         refreshIntervalSeconds = getConfigAs(WeatherCompanyForecastConfig.class).refreshInterval * 60;
96         if (isValidLocation()) {
97             weatherDataCache.clear();
98             setLanguage();
99             scheduleRefreshJob();
100             updateStatus(isBridgeOnline() ? ThingStatus.ONLINE : ThingStatus.OFFLINE);
101         }
102     }
103
104     @Override
105     public void dispose() {
106         cancelRefreshJob();
107         updateStatus(ThingStatus.OFFLINE);
108     }
109
110     @Override
111     public void handleCommand(ChannelUID channelUID, Command command) {
112         if (command.equals(RefreshType.REFRESH)) {
113             State state = weatherDataCache.get(channelUID.getId());
114             if (state != null) {
115                 updateChannel(channelUID.getId(), state);
116             }
117         }
118     }
119
120     private boolean isValidLocation() {
121         boolean validLocation = false;
122         WeatherCompanyForecastConfig config = getConfigAs(WeatherCompanyForecastConfig.class);
123         String locationType = config.locationType;
124         if (locationType == null) {
125             return validLocation;
126         }
127         switch (locationType) {
128             case CONFIG_LOCATION_TYPE_POSTAL_CODE:
129                 String postalCode = config.postalCode;
130                 if (postalCode == null || postalCode.isBlank()) {
131                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Postal code is not set");
132                 } else {
133                     locationQueryString = "&postalKey=" + postalCode.replace(" ", "");
134                     validLocation = true;
135                 }
136                 break;
137             case CONFIG_LOCATION_TYPE_GEOCODE:
138                 String geocode = config.geocode;
139                 if (geocode == null || geocode.isBlank()) {
140                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Geocode is not set");
141                 } else {
142                     locationQueryString = "&geocode=" + geocode.replace(" ", "");
143                     validLocation = true;
144                 }
145                 break;
146             case CONFIG_LOCATION_TYPE_IATA_CODE:
147                 String iataCode = config.iataCode;
148                 if (iataCode == null || iataCode.isBlank()) {
149                     updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "IATA code is not set");
150                 } else {
151                     locationQueryString = "&iataCode=" + iataCode.replace(" ", "").toUpperCase();
152                     validLocation = true;
153                 }
154                 break;
155             default:
156                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Location Type is not set");
157                 break;
158         }
159         return validLocation;
160     }
161
162     private void setLanguage() {
163         WeatherCompanyForecastConfig config = getConfigAs(WeatherCompanyForecastConfig.class);
164         String language = config.language;
165         if (language == null || language.isBlank()) {
166             // Nothing in the thing config, so try to get a match from the openHAB locale
167             String derivedLanguage = WeatherCompanyAbstractHandler.lookupLanguage(localeProvider.getLocale());
168             languageQueryString = "&language=" + derivedLanguage;
169             logger.debug("Language not set in thing config, using {}", derivedLanguage);
170         } else {
171             // Use what is set in the thing config
172             languageQueryString = "&language=" + language.trim();
173         }
174     }
175
176     /*
177      * Build the URL for requesting the 5-day forecast. It's important to request
178      * the desired language AND units so that the forecast narrative contains
179      * the consistent language and units (e.g. wind gusts to 30 mph).
180      */
181     private String buildForecastUrl() {
182         String apiKey = getApiKey();
183         StringBuilder sb = new StringBuilder(BASE_FORECAST_URL);
184         // Set response type as JSON
185         sb.append("?format=json");
186         // Set language from config
187         sb.append(languageQueryString);
188         // Set API key from config
189         sb.append("&apiKey=").append(apiKey);
190         // Set the units to Imperial or Metric
191         sb.append("&units=").append(getUnitsQueryString());
192         // Set the location from config
193         sb.append(locationQueryString);
194         String url = sb.toString();
195         logger.debug("Forecast URL is {}", url.replace(apiKey, REPLACE_API_KEY));
196         return url.toString();
197     }
198
199     private synchronized void refreshForecast() {
200         if (!isBridgeOnline()) {
201             // If bridge is not online, API has not been validated yet
202             logger.debug("Handler: Can't refresh forecast because bridge is not online");
203             return;
204         }
205         logger.debug("Handler: Requesting forecast from The Weather Company API");
206         String response = executeApiRequest(buildForecastUrl());
207         if (response == null) {
208             return;
209         }
210         try {
211             logger.trace("Handler: Parsing forecast response: {}", response);
212             ForecastDTO forecast = Objects.requireNonNull(gson.fromJson(response, ForecastDTO.class));
213             logger.debug("Handler: Successfully parsed daily forecast response object");
214             updateStatus(ThingStatus.ONLINE);
215             updateDailyForecast(forecast);
216             updateDaypartForecast(forecast.daypart);
217         } catch (JsonSyntaxException e) {
218             logger.debug("Handler: Error parsing daily forecast response object", e);
219             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error parsing daily forecast");
220             return;
221         }
222     }
223
224     private void updateDailyForecast(ForecastDTO forecast) {
225         for (int day = 0; day < forecast.dayOfWeek.length; day++) {
226             logger.debug("Processing daily forecast for '{}'", forecast.dayOfWeek[day]);
227             updateDaily(day, CH_DAY_OF_WEEK, undefOrString(forecast.dayOfWeek[day]));
228             updateDaily(day, CH_NARRATIVE, undefOrString(forecast.narrative[day]));
229             updateDaily(day, CH_VALID_TIME_LOCAL, undefOrDate(forecast.validTimeUtc[day]));
230             updateDaily(day, CH_EXPIRATION_TIME_LOCAL, undefOrDate(forecast.expirationTimeUtc[day]));
231             updateDaily(day, CH_TEMP_MAX, undefOrQuantity(forecast.temperatureMax[day], getTempUnit()));
232             updateDaily(day, CH_TEMP_MIN, undefOrQuantity(forecast.temperatureMin[day], getTempUnit()));
233             updateDaily(day, CH_PRECIP_RAIN, undefOrQuantity(forecast.qpf[day], getLengthUnit()));
234             updateDaily(day, CH_PRECIP_SNOW, undefOrQuantity(forecast.qpfSnow[day], getLengthUnit()));
235         }
236     }
237
238     private void updateDaypartForecast(Object daypartObject) {
239         DayPartDTO[] dayparts;
240         try {
241             String innerJson = gson.toJson(daypartObject);
242             logger.debug("Parsing daypartsObject: {}", innerJson);
243             dayparts = gson.fromJson(innerJson.toString(), DayPartDTO[].class);
244             logger.debug("Handler: Successfully parsed daypart forecast object");
245         } catch (JsonSyntaxException e) {
246             logger.debug("Handler: Error parsing daypart forecast object: {}", e.getMessage(), e);
247             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, "Error parsing daypart forecast");
248             return;
249         }
250         logger.debug("There are {} daypart forecast entries", dayparts.length);
251         if (dayparts.length == 0) {
252             logger.debug("There is no daypart forecast object in this message");
253             return;
254         }
255         logger.debug("There are {} daypartName entries in this forecast", dayparts[0].daypartName.length);
256         for (int i = 0; i < dayparts[0].daypartName.length; i++) {
257             // Note: All dayparts[0] (i.e. today day) values are null after 3 pm local time
258             DayPartDTO dp = dayparts[0];
259             // Even daypart indexes are Day (D); odd daypart indexes are Night (N)
260             String dOrN = dp.dayOrNight[i] == null ? (i % 2 == 0 ? "D" : "N") : dp.dayOrNight[i];
261             logger.debug("Processing daypart forecast for '{}'", dp.daypartName[i]);
262             updateDaypart(i, dOrN, CH_DP_NAME, undefOrString(dp.daypartName[i]));
263             updateDaypart(i, dOrN, CH_DP_DAY_OR_NIGHT, undefOrString(dayparts[0].dayOrNight[i]));
264             updateDaypart(i, dOrN, CH_DP_NARRATIVE, undefOrString(dayparts[0].narrative[i]));
265             updateDaypart(i, dOrN, CH_DP_WX_PHRASE_SHORT, undefOrString(dayparts[0].wxPhraseShort[i]));
266             updateDaypart(i, dOrN, CH_DP_WX_PHRASE_LONG, undefOrString(dayparts[0].wxPhraseLong[i]));
267             updateDaypart(i, dOrN, CH_DP_QUALIFIER_PHRASE, undefOrString(dayparts[0].qualifierPhrase[i]));
268             updateDaypart(i, dOrN, CH_DP_QUALIFIER_CODE, undefOrString(dayparts[0].qualifierCode[i]));
269             updateDaypart(i, dOrN, CH_DP_TEMP, undefOrQuantity(dp.temperature[i], getTempUnit()));
270             updateDaypart(i, dOrN, CH_DP_TEMP_HEAT_INDEX, undefOrQuantity(dp.temperatureHeatIndex[i], getTempUnit()));
271             updateDaypart(i, dOrN, CH_DP_TEMP_WIND_CHILL, undefOrQuantity(dp.temperatureWindChill[i], getTempUnit()));
272             updateDaypart(i, dOrN, CH_DP_HUMIDITY, undefOrQuantity(dp.relativeHumidity[i], Units.PERCENT));
273             updateDaypart(i, dOrN, CH_DP_CLOUD_COVER, undefOrQuantity(dp.cloudCover[i], Units.PERCENT));
274             updateDaypart(i, dOrN, CH_DP_PRECIP_CHANCE, undefOrQuantity(dp.precipChance[i], Units.PERCENT));
275             updateDaypart(i, dOrN, CH_DP_PRECIP_TYPE, undefOrString(dp.precipType[i]));
276             updateDaypart(i, dOrN, CH_DP_PRECIP_RAIN, undefOrQuantity(dp.qpf[i], getLengthUnit()));
277             updateDaypart(i, dOrN, CH_DP_PRECIP_SNOW, undefOrQuantity(dp.qpfSnow[i], getLengthUnit()));
278             updateDaypart(i, dOrN, CH_DP_SNOW_RANGE, undefOrString(dp.snowRange[i]));
279             updateDaypart(i, dOrN, CH_DP_WIND_SPEED, undefOrQuantity(dp.windSpeed[i], getSpeedUnit()));
280             updateDaypart(i, dOrN, CH_DP_WIND_DIR_CARDINAL, undefOrString(dp.windDirectionCardinal[i]));
281             updateDaypart(i, dOrN, CH_DP_WIND_PHRASE, undefOrString(dp.windPhrase[i]));
282             updateDaypart(i, dOrN, CH_DP_WIND_DIR, undefOrQuantity(dp.windDirection[i], Units.DEGREE_ANGLE));
283             updateDaypart(i, dOrN, CH_DP_THUNDER_CATEGORY, undefOrString(dp.thunderCategory[i]));
284             updateDaypart(i, dOrN, CH_DP_THUNDER_INDEX, undefOrDecimal(dp.thunderIndex[i]));
285             updateDaypart(i, dOrN, CH_DP_UV_DESCRIPTION, undefOrString(dp.uvDescription[i]));
286             updateDaypart(i, dOrN, CH_DP_UV_INDEX, undefOrDecimal(dp.uvIndex[i]));
287             updateDaypart(i, dOrN, CH_DP_ICON_CODE, undefOrDecimal(dp.iconCode[i]));
288             updateDaypart(i, dOrN, CH_DP_ICON_CODE_EXTEND, undefOrDecimal(dp.iconCodeExtend[i]));
289             updateDaypart(i, dOrN, CH_DP_ICON_IMAGE, getIconImage(dp.iconCode[i]));
290         }
291     }
292
293     private State getIconImage(Integer iconCode) {
294         // First try to get the image associated with the icon code
295         byte[] image = getImage("icons" + File.separator + String.format("%02d", iconCode) + ".png");
296         if (image != null) {
297             return new RawType(image, "image/png");
298         }
299         // Next try to get the N/A image
300         image = getImage("icons" + File.separator + "na.png");
301         if (image != null) {
302             return new RawType(image, "image/png");
303         }
304         // Couldn't get any icon image, so set to UNDEF
305         return UnDefType.UNDEF;
306     }
307
308     private byte @Nullable [] getImage(String iconPath) {
309         byte[] data = null;
310         URL url = FrameworkUtil.getBundle(getClass()).getResource(iconPath);
311         logger.trace("Path to icon image resource is: {}", url);
312         if (url != null) {
313             try (ByteArrayOutputStream out = new ByteArrayOutputStream()) {
314                 InputStream is = url.openStream();
315                 BufferedImage image = ImageIO.read(is);
316                 ImageIO.write(image, "png", out);
317                 out.flush();
318                 data = out.toByteArray();
319             } catch (IOException e) {
320                 logger.debug("I/O exception occurred getting image data: {}", e.getMessage(), e);
321             }
322         }
323         return data;
324     }
325
326     private void updateDaily(int day, String channelId, State state) {
327         updateChannel(CH_GROUP_FORECAST_DAY + String.valueOf(day) + "#" + channelId, state);
328     }
329
330     private void updateDaypart(int daypartIndex, String dayOrNight, String channelId, State state) {
331         int day = daypartIndex / 2;
332         String dON = dayOrNight.equals("D") ? CH_GROUP_FORECAST_DAYPART_DAY : CH_GROUP_FORECAST_DAYPART_NIGHT;
333         updateChannel(CH_GROUP_FORECAST_DAY + String.valueOf(day) + dON + "#" + channelId, state);
334     }
335
336     /*
337      * The refresh job updates the daily forecast on the
338      * refresh interval set in the thing config
339      */
340     private void scheduleRefreshJob() {
341         logger.debug("Handler: Scheduling forecast refresh job in {} seconds", REFRESH_JOB_INITIAL_DELAY_SECONDS);
342         cancelRefreshJob();
343         refreshForecastJob = scheduler.scheduleWithFixedDelay(refreshRunnable, REFRESH_JOB_INITIAL_DELAY_SECONDS,
344                 refreshIntervalSeconds, TimeUnit.SECONDS);
345     }
346
347     private void cancelRefreshJob() {
348         if (refreshForecastJob != null) {
349             refreshForecastJob.cancel(true);
350             logger.debug("Handler: Canceling forecast refresh job");
351         }
352     }
353 }