]> git.basschouten.com Git - openhab-addons.git/blob
3bc1c2484e3cffb60315f5aa17d244e0b2b709d7
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2023 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.meteoblue.internal.handler;
14
15 import static org.openhab.core.library.unit.MetricPrefix.*;
16
17 import java.awt.image.BufferedImage;
18 import java.io.ByteArrayOutputStream;
19 import java.io.IOException;
20 import java.math.BigDecimal;
21 import java.math.RoundingMode;
22 import java.time.ZoneId;
23 import java.time.ZonedDateTime;
24 import java.util.Calendar;
25 import java.util.concurrent.ScheduledFuture;
26 import java.util.concurrent.TimeUnit;
27
28 import javax.imageio.ImageIO;
29
30 import org.openhab.binding.meteoblue.internal.Forecast;
31 import org.openhab.binding.meteoblue.internal.MeteoBlueConfiguration;
32 import org.openhab.binding.meteoblue.internal.json.JsonData;
33 import org.openhab.core.io.net.http.HttpUtil;
34 import org.openhab.core.library.items.ImageItem;
35 import org.openhab.core.library.types.DateTimeType;
36 import org.openhab.core.library.types.DecimalType;
37 import org.openhab.core.library.types.QuantityType;
38 import org.openhab.core.library.types.RawType;
39 import org.openhab.core.library.types.StringType;
40 import org.openhab.core.library.unit.SIUnits;
41 import org.openhab.core.library.unit.Units;
42 import org.openhab.core.thing.Bridge;
43 import org.openhab.core.thing.Channel;
44 import org.openhab.core.thing.ChannelUID;
45 import org.openhab.core.thing.Thing;
46 import org.openhab.core.thing.ThingStatus;
47 import org.openhab.core.thing.ThingStatusDetail;
48 import org.openhab.core.thing.binding.BaseThingHandler;
49 import org.openhab.core.types.Command;
50 import org.openhab.core.types.State;
51 import org.slf4j.Logger;
52 import org.slf4j.LoggerFactory;
53
54 import com.google.gson.Gson;
55
56 /**
57  * The {@link MeteoBlueHandler} is responsible for handling commands
58  * sent to one of the channels.
59  *
60  * @author Chris Carman - Initial contribution
61  */
62 public class MeteoBlueHandler extends BaseThingHandler {
63     private final Logger logger = LoggerFactory.getLogger(MeteoBlueHandler.class);
64
65     private Bridge bridge;
66     private Forecast[] forecasts;
67     private Gson gson;
68     private JsonData weatherData;
69     private ScheduledFuture<?> refreshJob;
70     private boolean properlyConfigured;
71
72     public MeteoBlueHandler(Thing thing) {
73         super(thing);
74         gson = new Gson();
75         forecasts = new Forecast[7];
76     }
77
78     @Override
79     public void handleCommand(ChannelUID channelUID, Command command) {
80         if (properlyConfigured) {
81             logger.debug("Received command '{}' for channel '{}'", command, channelUID);
82             updateChannel(channelUID.getId());
83         }
84     }
85
86     @Override
87     public void initialize() {
88         logger.debug("Initializing the meteoblue handler...");
89
90         bridge = getBridge();
91         if (bridge == null) {
92             logger.warn("Unable to initialize meteoblue. No bridge was configured.");
93             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Bridge not configured.");
94             return;
95         }
96
97         MeteoBlueConfiguration config = getConfigAs(MeteoBlueConfiguration.class);
98
99         if (config.serviceType == null || config.serviceType.isBlank()) {
100             config.serviceType = MeteoBlueConfiguration.SERVICETYPE_NONCOMM;
101             logger.debug("Using default service type ({}).", config.serviceType);
102             return;
103         }
104
105         if (config.location == null || config.location.isBlank()) {
106             flagBadConfig("The location was not configured.");
107             return;
108         }
109
110         config.parseLocation();
111
112         if (config.latitude == null) {
113             flagBadConfig(String.format("Could not determine latitude from the defined location setting (%s).",
114                     config.location));
115             return;
116         }
117
118         if (config.latitude > 90.0 || config.latitude < -90.0) {
119             flagBadConfig(String.format("Specified latitude value (%d) is not valid.", config.latitude));
120             return;
121         }
122
123         if (config.longitude == null) {
124             flagBadConfig(String.format("Could not determine longitude from the defined location setting (%s).",
125                     config.location));
126             return;
127         }
128
129         if (config.longitude > 180.0 || config.longitude < -180.0) {
130             flagBadConfig(String.format("Specified longitude value (%d) is not valid.", config.longitude));
131             return;
132         }
133
134         updateStatus(ThingStatus.UNKNOWN);
135         startAutomaticRefresh(config);
136         properlyConfigured = true;
137     }
138
139     /**
140      * Marks the configuration as invalid.
141      */
142     private void flagBadConfig(String message) {
143         properlyConfigured = false;
144         updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, message);
145     }
146
147     /**
148      * Schedule a job to periodically refresh the weather data.
149      */
150     private void startAutomaticRefresh(MeteoBlueConfiguration config) {
151         if (refreshJob != null && !refreshJob.isCancelled()) {
152             logger.trace("Refresh job already exists.");
153             return;
154         }
155
156         Runnable runnable = () -> {
157             boolean updateSuccessful = false;
158
159             try {
160                 // Request new weather data
161                 updateSuccessful = updateWeatherData();
162
163                 if (updateSuccessful) {
164                     // build forecasts from the data
165                     for (int i = 0; i < 7; i++) {
166                         forecasts[i] = new Forecast(i, weatherData.getMetadata(), weatherData.getUnits(),
167                                 weatherData.getDataDay());
168                     }
169
170                     // Update all channels from the updated weather data
171                     for (Channel channel : getThing().getChannels()) {
172                         updateChannel(channel.getUID().getId());
173                     }
174                 }
175             } catch (Exception e) {
176                 logger.warn("Exception occurred during weather update: {}", e.getMessage(), e);
177             }
178         };
179
180         int period = config.refresh != null ? config.refresh : MeteoBlueConfiguration.DEFAULT_REFRESH;
181         refreshJob = scheduler.scheduleWithFixedDelay(runnable, 0, period, TimeUnit.MINUTES);
182     }
183
184     @Override
185     public void dispose() {
186         logger.debug("Disposing meteoblue handler.");
187
188         if (refreshJob != null && !refreshJob.isCancelled()) {
189             refreshJob.cancel(true);
190             refreshJob = null;
191         }
192     }
193
194     /**
195      * Update the channel from the last weather data retrieved.
196      *
197      * @param channelId the id of the channel to be updated
198      */
199     private void updateChannel(String channelId) {
200         Channel channel = getThing().getChannel(channelId);
201         if (channel == null || !isLinked(channelId)) {
202             logger.trace("Channel '{}' was null or not linked! Not updated.", channelId);
203             return;
204         }
205
206         // get the set of channel parameters.
207         // the first will be the forecast day (eg. forecastToday),
208         // and the second will be the datapoint (eg. snowFraction)
209         String[] channelParts = channelId.split("#");
210         String forecastDay = channelParts[0];
211         String datapointName = channelParts[1];
212         if (channelParts.length != 2) {
213             logger.debug("Skipped invalid channelId '{}'", channelId);
214             return;
215         }
216
217         logger.debug("Updating channel '{}'", channelId);
218         Forecast forecast = getForecast(forecastDay);
219         if (forecast == null) {
220             logger.debug("No forecast found for '{}'. Not updating.", forecastDay);
221             return;
222         }
223
224         Object datapoint = forecast.getDatapoint(datapointName);
225         logger.debug("Value for datapoint '{}' is '{}'", datapointName, datapoint);
226         if (datapoint == null) {
227             logger.debug("Couldn't get datapoint '{}' for '{}'. Not updating.", datapointName, forecastDay);
228             return;
229         }
230
231         // Build a State from this value
232         State state = null;
233         if (datapoint instanceof Calendar) {
234             state = new DateTimeType(
235                     ZonedDateTime.ofInstant(((Calendar) datapoint).toInstant(), ZoneId.systemDefault()));
236         } else if (datapoint instanceof Integer) {
237             state = getStateForType(channel.getAcceptedItemType(), (Integer) datapoint);
238         } else if (datapoint instanceof Number) {
239             BigDecimal decimalValue = new BigDecimal(datapoint.toString()).setScale(2, RoundingMode.HALF_UP);
240             state = getStateForType(channel.getAcceptedItemType(), decimalValue);
241         } else if (datapoint instanceof String) {
242             state = new StringType(datapoint.toString());
243         } else if (datapoint instanceof BufferedImage) {
244             ImageItem item = new ImageItem("rain area");
245             state = new RawType(renderImage((BufferedImage) datapoint), "image/png");
246             item.setState(state);
247         } else {
248             logger.debug("Unsupported value type {}", datapoint.getClass().getSimpleName());
249         }
250
251         // Update the channel
252         if (state != null) {
253             logger.trace("Updating channel with state value {}. (object type {})", state,
254                     datapoint.getClass().getSimpleName());
255             updateState(channelId, state);
256         }
257     }
258
259     private State getStateForType(String type, Integer value) {
260         return getStateForType(type, new BigDecimal(value));
261     }
262
263     private State getStateForType(String type, BigDecimal value) {
264         State state = new DecimalType(value);
265
266         if (type.equals("Number:Temperature")) {
267             state = new QuantityType<>(value, SIUnits.CELSIUS);
268         } else if (type.equals("Number:Length")) {
269             state = new QuantityType<>(value, MILLI(SIUnits.METRE));
270         } else if (type.equals("Number:Pressure")) {
271             state = new QuantityType<>(value, HECTO(SIUnits.PASCAL));
272         } else if (type.equals("Number:Speed")) {
273             state = new QuantityType<>(value, Units.METRE_PER_SECOND);
274         }
275
276         return state;
277     }
278
279     // Request new weather data from the service
280     private boolean updateWeatherData() {
281         if (bridge == null) {
282             logger.debug("Unable to update weather data. Bridge missing.");
283             return false;
284         }
285
286         MeteoBlueBridgeHandler handler = (MeteoBlueBridgeHandler) bridge.getHandler();
287         if (handler == null) {
288             logger.debug("Unable to update weather data. Handler missing.");
289             return false;
290         }
291
292         String apiKey = handler.getApiKey();
293
294         logger.debug("Updating weather data...");
295         MeteoBlueConfiguration config = getConfigAs(MeteoBlueConfiguration.class);
296         config.parseLocation();
297         String serviceType = config.serviceType;
298
299         if (serviceType.equals(MeteoBlueConfiguration.SERVICETYPE_COMM)) {
300             logger.debug("Fetching weather data using Commercial API.");
301         } else {
302             logger.debug("Fetching weather data using NonCommercial API.");
303         }
304
305         // get the base url for the HTTP query
306         String url = MeteoBlueConfiguration.getURL(serviceType);
307         url = url.replace("#API_KEY#", apiKey);
308         url = url.replace("#LATITUDE#", String.valueOf(config.latitude)).replace("#LONGITUDE#",
309                 String.valueOf(config.longitude));
310
311         // fill in any optional parameters for the HTTP query
312         StringBuilder builder = new StringBuilder();
313
314         if (config.altitude != null) {
315             builder.append("&asl=" + config.altitude);
316         }
317         if (config.timeZone != null && !config.timeZone.isBlank()) {
318             builder.append("&tz=" + config.timeZone);
319         }
320         url = url.replace("#FORMAT_PARAMS#", builder.toString());
321         logger.trace("Using URL '{}'", url);
322
323         // Run the HTTP request and get the JSON response
324         String httpResponse = getWeatherData(url);
325         if (httpResponse == null) {
326             return false;
327         }
328         JsonData jsonResult = translateJson(httpResponse, serviceType);
329         logger.trace("json object: {}", jsonResult);
330
331         if (jsonResult == null) {
332             logger.warn("No data was received from the weather service");
333             return false;
334         }
335
336         String errorMessage = jsonResult.getErrorMessage();
337         if (errorMessage != null) {
338             if (errorMessage.equals("MB_REQUEST::DISPATCH: Invalid api key")) {
339                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid API Key");
340             } else if (errorMessage.equals("MB_REQUEST::DISPATCH: This datafeed is not authorized for your api key")) {
341                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
342                         "API Key not authorized for this datafeed");
343             } else {
344                 logger.warn("Failed to retrieve weather data due to unexpected error. Error message: {}", errorMessage);
345                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, errorMessage);
346             }
347             return false;
348         }
349
350         weatherData = jsonResult;
351         updateStatus(ThingStatus.ONLINE);
352         return true;
353     }
354
355     // Run the HTTP request and get the JSON response
356     private String getWeatherData(String url) {
357         try {
358             String httpResponse = HttpUtil.executeUrl("GET", url, 30 * 1000);
359             logger.trace("http response: {}", httpResponse);
360             return httpResponse;
361         } catch (IOException e) {
362             logger.debug("I/O Exception occurred while retrieving weather data.", e);
363             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
364                     "I/O Exception occurred while retrieving weather data.");
365             return null;
366         }
367     }
368
369     // Convert a json string response into a json data object
370     private JsonData translateJson(String stringData, String serviceType) {
371         // JsonData weatherData = null;
372
373         // For now, no distinction is made between commercial and non-commercial data;
374         // This may need to be changed later based on user feedback.
375         /*
376          * if (serviceType.equals(MeteoBlueConfiguration.SERVICETYPE_COMM)) {
377          * weatherData = gson.fromJson(httpResponse, JsonCommercialData.class);
378          * }
379          * else {
380          * weatherData = gson.fromJson(httpResponse, JsonNonCommercialData.class);
381          * }
382          */
383
384         return gson.fromJson(stringData, JsonData.class);
385     }
386
387     private Forecast getForecast(String which) {
388         switch (which) {
389             case "forecastToday":
390                 return forecasts[0];
391             case "forecastTomorrow":
392                 return forecasts[1];
393             case "forecastDay2":
394                 return forecasts[2];
395             case "forecastDay3":
396                 return forecasts[3];
397             case "forecastDay4":
398                 return forecasts[4];
399             case "forecastDay5":
400                 return forecasts[5];
401             case "forecastDay6":
402                 return forecasts[6];
403             default:
404                 return null;
405         }
406     }
407
408     private byte[] renderImage(BufferedImage image) {
409         byte[] data = null;
410
411         try {
412             ByteArrayOutputStream out = new ByteArrayOutputStream();
413             ImageIO.write(image, "png", out);
414             out.flush();
415             data = out.toByteArray();
416             out.close();
417         } catch (IOException ioe) {
418             logger.debug("I/O exception occurred converting image data", ioe);
419         }
420
421         return data;
422     }
423 }