]> git.basschouten.com Git - openhab-addons.git/blob
66f2f82f8b0e6835f39d76fbf742f52502db45c4
[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 calendar) {
234             state = new DateTimeType(ZonedDateTime.ofInstant(calendar.toInstant(), ZoneId.systemDefault()));
235         } else if (datapoint instanceof Integer) {
236             state = getStateForType(channel.getAcceptedItemType(), (Integer) datapoint);
237         } else if (datapoint instanceof Number) {
238             BigDecimal decimalValue = new BigDecimal(datapoint.toString()).setScale(2, RoundingMode.HALF_UP);
239             state = getStateForType(channel.getAcceptedItemType(), decimalValue);
240         } else if (datapoint instanceof String) {
241             state = new StringType(datapoint.toString());
242         } else if (datapoint instanceof BufferedImage image) {
243             ImageItem item = new ImageItem("rain area");
244             state = new RawType(renderImage(image), "image/png");
245             item.setState(state);
246         } else {
247             logger.debug("Unsupported value type {}", datapoint.getClass().getSimpleName());
248         }
249
250         // Update the channel
251         if (state != null) {
252             logger.trace("Updating channel with state value {}. (object type {})", state,
253                     datapoint.getClass().getSimpleName());
254             updateState(channelId, state);
255         }
256     }
257
258     private State getStateForType(String type, Integer value) {
259         return getStateForType(type, new BigDecimal(value));
260     }
261
262     private State getStateForType(String type, BigDecimal value) {
263         State state = new DecimalType(value);
264
265         if ("Number:Temperature".equals(type)) {
266             state = new QuantityType<>(value, SIUnits.CELSIUS);
267         } else if ("Number:Length".equals(type)) {
268             state = new QuantityType<>(value, MILLI(SIUnits.METRE));
269         } else if ("Number:Pressure".equals(type)) {
270             state = new QuantityType<>(value, HECTO(SIUnits.PASCAL));
271         } else if ("Number:Speed".equals(type)) {
272             state = new QuantityType<>(value, Units.METRE_PER_SECOND);
273         }
274
275         return state;
276     }
277
278     // Request new weather data from the service
279     private boolean updateWeatherData() {
280         if (bridge == null) {
281             logger.debug("Unable to update weather data. Bridge missing.");
282             return false;
283         }
284
285         MeteoBlueBridgeHandler handler = (MeteoBlueBridgeHandler) bridge.getHandler();
286         if (handler == null) {
287             logger.debug("Unable to update weather data. Handler missing.");
288             return false;
289         }
290
291         String apiKey = handler.getApiKey();
292
293         logger.debug("Updating weather data...");
294         MeteoBlueConfiguration config = getConfigAs(MeteoBlueConfiguration.class);
295         config.parseLocation();
296         String serviceType = config.serviceType;
297
298         if (serviceType.equals(MeteoBlueConfiguration.SERVICETYPE_COMM)) {
299             logger.debug("Fetching weather data using Commercial API.");
300         } else {
301             logger.debug("Fetching weather data using NonCommercial API.");
302         }
303
304         // get the base url for the HTTP query
305         String url = MeteoBlueConfiguration.getURL(serviceType);
306         url = url.replace("#API_KEY#", apiKey);
307         url = url.replace("#LATITUDE#", String.valueOf(config.latitude)).replace("#LONGITUDE#",
308                 String.valueOf(config.longitude));
309
310         // fill in any optional parameters for the HTTP query
311         StringBuilder builder = new StringBuilder();
312
313         if (config.altitude != null) {
314             builder.append("&asl=" + config.altitude);
315         }
316         if (config.timeZone != null && !config.timeZone.isBlank()) {
317             builder.append("&tz=" + config.timeZone);
318         }
319         url = url.replace("#FORMAT_PARAMS#", builder.toString());
320         logger.trace("Using URL '{}'", url);
321
322         // Run the HTTP request and get the JSON response
323         String httpResponse = getWeatherData(url);
324         if (httpResponse == null) {
325             return false;
326         }
327         JsonData jsonResult = translateJson(httpResponse, serviceType);
328         logger.trace("json object: {}", jsonResult);
329
330         if (jsonResult == null) {
331             logger.warn("No data was received from the weather service");
332             return false;
333         }
334
335         String errorMessage = jsonResult.getErrorMessage();
336         if (errorMessage != null) {
337             if ("MB_REQUEST::DISPATCH: Invalid api key".equals(errorMessage)) {
338                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Invalid API Key");
339             } else if ("MB_REQUEST::DISPATCH: This datafeed is not authorized for your api key".equals(errorMessage)) {
340                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
341                         "API Key not authorized for this datafeed");
342             } else {
343                 logger.warn("Failed to retrieve weather data due to unexpected error. Error message: {}", errorMessage);
344                 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, errorMessage);
345             }
346             return false;
347         }
348
349         weatherData = jsonResult;
350         updateStatus(ThingStatus.ONLINE);
351         return true;
352     }
353
354     // Run the HTTP request and get the JSON response
355     private String getWeatherData(String url) {
356         try {
357             String httpResponse = HttpUtil.executeUrl("GET", url, 30 * 1000);
358             logger.trace("http response: {}", httpResponse);
359             return httpResponse;
360         } catch (IOException e) {
361             logger.debug("I/O Exception occurred while retrieving weather data.", e);
362             updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
363                     "I/O Exception occurred while retrieving weather data.");
364             return null;
365         }
366     }
367
368     // Convert a json string response into a json data object
369     private JsonData translateJson(String stringData, String serviceType) {
370         // JsonData weatherData = null;
371
372         // For now, no distinction is made between commercial and non-commercial data;
373         // This may need to be changed later based on user feedback.
374         /*
375          * if (serviceType.equals(MeteoBlueConfiguration.SERVICETYPE_COMM)) {
376          * weatherData = gson.fromJson(httpResponse, JsonCommercialData.class);
377          * }
378          * else {
379          * weatherData = gson.fromJson(httpResponse, JsonNonCommercialData.class);
380          * }
381          */
382
383         return gson.fromJson(stringData, JsonData.class);
384     }
385
386     private Forecast getForecast(String which) {
387         switch (which) {
388             case "forecastToday":
389                 return forecasts[0];
390             case "forecastTomorrow":
391                 return forecasts[1];
392             case "forecastDay2":
393                 return forecasts[2];
394             case "forecastDay3":
395                 return forecasts[3];
396             case "forecastDay4":
397                 return forecasts[4];
398             case "forecastDay5":
399                 return forecasts[5];
400             case "forecastDay6":
401                 return forecasts[6];
402             default:
403                 return null;
404         }
405     }
406
407     private byte[] renderImage(BufferedImage image) {
408         byte[] data = null;
409
410         try {
411             ByteArrayOutputStream out = new ByteArrayOutputStream();
412             ImageIO.write(image, "png", out);
413             out.flush();
414             data = out.toByteArray();
415             out.close();
416         } catch (IOException ioe) {
417             logger.debug("I/O exception occurred converting image data", ioe);
418         }
419
420         return data;
421     }
422 }