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.fmiweather.internal;
15 import static org.openhab.binding.fmiweather.internal.BindingConstants.*;
16 import static org.openhab.binding.fmiweather.internal.client.ForecastRequest.*;
17 import static org.openhab.core.library.unit.SIUnits.CELSIUS;
18 import static org.openhab.core.library.unit.Units.*;
20 import java.math.BigDecimal;
21 import java.time.Instant;
22 import java.util.AbstractMap;
23 import java.util.HashMap;
25 import java.util.Map.Entry;
26 import java.util.concurrent.TimeUnit;
28 import javax.measure.Unit;
30 import org.eclipse.jdt.annotation.NonNullByDefault;
31 import org.eclipse.jdt.annotation.Nullable;
32 import org.openhab.binding.fmiweather.internal.client.Data;
33 import org.openhab.binding.fmiweather.internal.client.FMIResponse;
34 import org.openhab.binding.fmiweather.internal.client.ForecastRequest;
35 import org.openhab.binding.fmiweather.internal.client.LatLon;
36 import org.openhab.binding.fmiweather.internal.client.Location;
37 import org.openhab.binding.fmiweather.internal.client.Request;
38 import org.openhab.binding.fmiweather.internal.client.exception.FMIUnexpectedResponseException;
39 import org.openhab.core.thing.Channel;
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.slf4j.Logger;
45 import org.slf4j.LoggerFactory;
48 * The {@link ForecastWeatherHandler} is responsible for handling commands, which are
49 * sent to one of the channels.
51 * @author Sami Salonen - Initial contribution
54 public class ForecastWeatherHandler extends AbstractWeatherHandler {
56 private final Logger logger = LoggerFactory.getLogger(ForecastWeatherHandler.class);
58 private static final String GROUP_FORECAST_NOW = "forecastNow";
59 private static final int QUERY_RESOLUTION_MINUTES = 20; // The channel group hours should be divisible by this
60 // Hirlam horizon is 54h https://ilmatieteenlaitos.fi/avoin-data-avattavat-aineistot (in Finnish)
61 private static final int FORECAST_HORIZON_HOURS = 50; // should be divisible by QUERY_RESOLUTION_MINUTES
62 private static final Map<String, Map.Entry<String, @Nullable Unit<?>>> CHANNEL_TO_FORECAST_FIELD_NAME_AND_UNIT = new HashMap<>(
65 private static void addMapping(String channelId, String requestField, @Nullable Unit<?> unit) {
66 CHANNEL_TO_FORECAST_FIELD_NAME_AND_UNIT.put(channelId,
67 new AbstractMap.SimpleImmutableEntry<>(requestField, unit));
71 addMapping(CHANNEL_TEMPERATURE, PARAM_TEMPERATURE, CELSIUS);
72 addMapping(CHANNEL_HUMIDITY, PARAM_HUMIDITY, PERCENT);
73 addMapping(CHANNEL_WIND_DIRECTION, PARAM_WIND_DIRECTION, DEGREE_ANGLE);
74 addMapping(CHANNEL_WIND_SPEED, PARAM_WIND_SPEED, METRE_PER_SECOND);
75 addMapping(CHANNEL_GUST, PARAM_WIND_GUST, METRE_PER_SECOND);
76 addMapping(CHANNEL_PRESSURE, PARAM_PRESSURE, MILLIBAR);
77 addMapping(CHANNEL_PRECIPITATION_INTENSITY, PARAM_PRECIPITATION_1H, MILLIMETRE_PER_HOUR);
78 addMapping(CHANNEL_TOTAL_CLOUD_COVER, PARAM_TOTAL_CLOUD_COVER, PERCENT);
79 addMapping(CHANNEL_FORECAST_WEATHER_ID, PARAM_WEATHER_SYMBOL, null);
82 private @NonNullByDefault({}) LatLon location;
84 public ForecastWeatherHandler(Thing thing) {
86 // Override poll interval to slower value
87 pollIntervalSeconds = (int) TimeUnit.MINUTES.toSeconds(QUERY_RESOLUTION_MINUTES);
91 public void initialize() {
93 Object location = getConfig().get(BindingConstants.LOCATION);
94 if (location == null) {
95 logger.debug("Location not set for thing {} -- aborting initialization.", getThing().getUID());
96 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
97 String.format("location parameter not set"));
100 String latlon = location.toString();
101 String[] split = latlon.split(",");
102 if (split.length != 2) {
103 throw new NumberFormatException(String.format(
104 "Expecting location parameter to have latitude and longitude separated by comma (LATITUDE,LONGITUDE). Found %d values instead.",
107 this.location = new LatLon(new BigDecimal(split[0].trim()), new BigDecimal(split[1].trim()));
109 } catch (NumberFormatException e) {
110 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String.format(
111 "location parameter should be in format LATITUDE,LONGITUDE. Error details: %s", e.getMessage()));
116 public void dispose() {
118 this.location = null;
122 protected Request getRequest() {
123 long now = Instant.now().getEpochSecond();
124 return new ForecastRequest(location, floorToEvenMinutes(now, QUERY_RESOLUTION_MINUTES),
125 ceilToEvenMinutes(now + TimeUnit.HOURS.toSeconds(FORECAST_HORIZON_HOURS), QUERY_RESOLUTION_MINUTES),
126 QUERY_RESOLUTION_MINUTES);
130 protected void updateChannels() {
131 FMIResponse response = this.response;
132 if (response == null) {
136 Location location = unwrap(response.getLocations().stream().findFirst(),
137 "No locations in response -- no data? Aborting");
138 Map<String, String> properties = editProperties();
139 properties.put(PROP_NAME, location.name);
140 properties.put(PROP_LATITUDE, location.latitude.toPlainString());
141 properties.put(PROP_LONGITUDE, location.longitude.toPlainString());
142 updateProperties(properties);
143 for (Channel channel : getThing().getChannels()) {
144 ChannelUID channelUID = channel.getUID();
145 int hours = getHours(channelUID);
146 int timeIndex = getTimeIndex(hours);
147 if (channelUID.getIdWithoutGroup().equals(CHANNEL_TIME)) {
148 // All parameters and locations should share the same timestamps. We use temperature to figure out
149 // timestamp for the group of channels
150 String field = ForecastRequest.PARAM_TEMPERATURE;
151 Data data = unwrap(response.getData(location, field),
152 "Field %s not present for location %s in response. Bug?", field, location);
153 updateEpochSecondStateIfLinked(channelUID, data.timestampsEpochSecs[timeIndex]);
155 String field = getDataField(channelUID);
156 Unit<?> unit = getUnit(channelUID);
158 logger.error("Channel {} not handled. Bug?", channelUID.getId());
161 Data data = unwrap(response.getData(location, field),
162 "Field %s not present for location %s in response. Bug?", field, location);
163 updateStateIfLinked(channelUID, data.values[timeIndex], unit);
166 updateStatus(ThingStatus.ONLINE);
167 } catch (FMIUnexpectedResponseException e) {
168 // Unexpected (possibly bug) issue with response
169 logger.warn("Unexpected response encountered: {}", e.getMessage());
170 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
171 String.format("Unexpected API response: %s", e.getMessage()));
175 private static int getHours(ChannelUID uid) {
176 String groupId = uid.getGroupId();
177 if (groupId == null) {
178 throw new IllegalStateException("All channels should be in group!");
180 if (GROUP_FORECAST_NOW.equals(groupId)) {
183 return Integer.valueOf(groupId.substring(groupId.length() - 2));
187 private static int getTimeIndex(int hours) {
188 return (int) (TimeUnit.HOURS.toMinutes(hours) / QUERY_RESOLUTION_MINUTES);
191 @SuppressWarnings({ "unused", "null" })
192 private static @Nullable String getDataField(ChannelUID channelUID) {
193 Entry<String, @Nullable Unit<?>> entry = CHANNEL_TO_FORECAST_FIELD_NAME_AND_UNIT
194 .get(channelUID.getIdWithoutGroup());
198 return entry.getKey();
201 @SuppressWarnings({ "unused", "null" })
202 private static @Nullable Unit<?> getUnit(ChannelUID channelUID) {
203 Entry<String, @Nullable Unit<?>> entry = CHANNEL_TO_FORECAST_FIELD_NAME_AND_UNIT
204 .get(channelUID.getIdWithoutGroup());
208 return entry.getValue();