2 * Copyright (c) 2010-2023 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.meteoblue.internal.handler;
15 import static org.openhab.core.library.unit.MetricPrefix.*;
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;
28 import javax.imageio.ImageIO;
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;
54 import com.google.gson.Gson;
57 * The {@link MeteoBlueHandler} is responsible for handling commands
58 * sent to one of the channels.
60 * @author Chris Carman - Initial contribution
62 public class MeteoBlueHandler extends BaseThingHandler {
63 private final Logger logger = LoggerFactory.getLogger(MeteoBlueHandler.class);
65 private Bridge bridge;
66 private Forecast[] forecasts;
68 private JsonData weatherData;
69 private ScheduledFuture<?> refreshJob;
70 private boolean properlyConfigured;
72 public MeteoBlueHandler(Thing thing) {
75 forecasts = new Forecast[7];
79 public void handleCommand(ChannelUID channelUID, Command command) {
80 if (properlyConfigured) {
81 logger.debug("Received command '{}' for channel '{}'", command, channelUID);
82 updateChannel(channelUID.getId());
87 public void initialize() {
88 logger.debug("Initializing the meteoblue handler...");
92 logger.warn("Unable to initialize meteoblue. No bridge was configured.");
93 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, "Bridge not configured.");
97 MeteoBlueConfiguration config = getConfigAs(MeteoBlueConfiguration.class);
99 if (config.serviceType == null || config.serviceType.isBlank()) {
100 config.serviceType = MeteoBlueConfiguration.SERVICETYPE_NONCOMM;
101 logger.debug("Using default service type ({}).", config.serviceType);
105 if (config.location == null || config.location.isBlank()) {
106 flagBadConfig("The location was not configured.");
110 config.parseLocation();
112 if (config.latitude == null) {
113 flagBadConfig(String.format("Could not determine latitude from the defined location setting (%s).",
118 if (config.latitude > 90.0 || config.latitude < -90.0) {
119 flagBadConfig(String.format("Specified latitude value (%d) is not valid.", config.latitude));
123 if (config.longitude == null) {
124 flagBadConfig(String.format("Could not determine longitude from the defined location setting (%s).",
129 if (config.longitude > 180.0 || config.longitude < -180.0) {
130 flagBadConfig(String.format("Specified longitude value (%d) is not valid.", config.longitude));
134 updateStatus(ThingStatus.UNKNOWN);
135 startAutomaticRefresh(config);
136 properlyConfigured = true;
140 * Marks the configuration as invalid.
142 private void flagBadConfig(String message) {
143 properlyConfigured = false;
144 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, message);
148 * Schedule a job to periodically refresh the weather data.
150 private void startAutomaticRefresh(MeteoBlueConfiguration config) {
151 if (refreshJob != null && !refreshJob.isCancelled()) {
152 logger.trace("Refresh job already exists.");
156 Runnable runnable = () -> {
157 boolean updateSuccessful = false;
160 // Request new weather data
161 updateSuccessful = updateWeatherData();
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());
170 // Update all channels from the updated weather data
171 for (Channel channel : getThing().getChannels()) {
172 updateChannel(channel.getUID().getId());
175 } catch (Exception e) {
176 logger.warn("Exception occurred during weather update: {}", e.getMessage(), e);
180 int period = config.refresh != null ? config.refresh : MeteoBlueConfiguration.DEFAULT_REFRESH;
181 refreshJob = scheduler.scheduleWithFixedDelay(runnable, 0, period, TimeUnit.MINUTES);
185 public void dispose() {
186 logger.debug("Disposing meteoblue handler.");
188 if (refreshJob != null && !refreshJob.isCancelled()) {
189 refreshJob.cancel(true);
195 * Update the channel from the last weather data retrieved.
197 * @param channelId the id of the channel to be updated
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);
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);
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);
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);
231 // Build a State from this value
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);
248 logger.debug("Unsupported value type {}", datapoint.getClass().getSimpleName());
251 // Update the channel
253 logger.trace("Updating channel with state value {}. (object type {})", state,
254 datapoint.getClass().getSimpleName());
255 updateState(channelId, state);
259 private State getStateForType(String type, Integer value) {
260 return getStateForType(type, new BigDecimal(value));
263 private State getStateForType(String type, BigDecimal value) {
264 State state = new DecimalType(value);
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);
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.");
286 MeteoBlueBridgeHandler handler = (MeteoBlueBridgeHandler) bridge.getHandler();
287 if (handler == null) {
288 logger.debug("Unable to update weather data. Handler missing.");
292 String apiKey = handler.getApiKey();
294 logger.debug("Updating weather data...");
295 MeteoBlueConfiguration config = getConfigAs(MeteoBlueConfiguration.class);
296 config.parseLocation();
297 String serviceType = config.serviceType;
299 if (serviceType.equals(MeteoBlueConfiguration.SERVICETYPE_COMM)) {
300 logger.debug("Fetching weather data using Commercial API.");
302 logger.debug("Fetching weather data using NonCommercial API.");
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));
311 // fill in any optional parameters for the HTTP query
312 StringBuilder builder = new StringBuilder();
314 if (config.altitude != null) {
315 builder.append("&asl=" + config.altitude);
317 if (config.timeZone != null && !config.timeZone.isBlank()) {
318 builder.append("&tz=" + config.timeZone);
320 url = url.replace("#FORMAT_PARAMS#", builder.toString());
321 logger.trace("Using URL '{}'", url);
323 // Run the HTTP request and get the JSON response
324 String httpResponse = getWeatherData(url);
325 if (httpResponse == null) {
328 JsonData jsonResult = translateJson(httpResponse, serviceType);
329 logger.trace("json object: {}", jsonResult);
331 if (jsonResult == null) {
332 logger.warn("No data was received from the weather service");
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");
344 logger.warn("Failed to retrieve weather data due to unexpected error. Error message: {}", errorMessage);
345 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR, errorMessage);
350 weatherData = jsonResult;
351 updateStatus(ThingStatus.ONLINE);
355 // Run the HTTP request and get the JSON response
356 private String getWeatherData(String url) {
358 String httpResponse = HttpUtil.executeUrl("GET", url, 30 * 1000);
359 logger.trace("http response: {}", 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.");
369 // Convert a json string response into a json data object
370 private JsonData translateJson(String stringData, String serviceType) {
371 // JsonData weatherData = null;
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.
376 * if (serviceType.equals(MeteoBlueConfiguration.SERVICETYPE_COMM)) {
377 * weatherData = gson.fromJson(httpResponse, JsonCommercialData.class);
380 * weatherData = gson.fromJson(httpResponse, JsonNonCommercialData.class);
384 return gson.fromJson(stringData, JsonData.class);
387 private Forecast getForecast(String which) {
389 case "forecastToday":
391 case "forecastTomorrow":
408 private byte[] renderImage(BufferedImage image) {
412 ByteArrayOutputStream out = new ByteArrayOutputStream();
413 ImageIO.write(image, "png", out);
415 data = out.toByteArray();
417 } catch (IOException ioe) {
418 logger.debug("I/O exception occurred converting image data", ioe);