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.*;
19 import static org.openhab.core.types.TimeSeries.Policy.REPLACE;
21 import java.math.BigDecimal;
22 import java.time.Instant;
23 import java.util.AbstractMap;
24 import java.util.HashMap;
26 import java.util.Map.Entry;
27 import java.util.concurrent.TimeUnit;
29 import javax.measure.Unit;
31 import org.eclipse.jdt.annotation.NonNullByDefault;
32 import org.eclipse.jdt.annotation.Nullable;
33 import org.openhab.binding.fmiweather.internal.client.Data;
34 import org.openhab.binding.fmiweather.internal.client.FMIResponse;
35 import org.openhab.binding.fmiweather.internal.client.ForecastRequest;
36 import org.openhab.binding.fmiweather.internal.client.LatLon;
37 import org.openhab.binding.fmiweather.internal.client.Location;
38 import org.openhab.binding.fmiweather.internal.client.Request;
39 import org.openhab.binding.fmiweather.internal.client.exception.FMIUnexpectedResponseException;
40 import org.openhab.core.thing.Channel;
41 import org.openhab.core.thing.ChannelUID;
42 import org.openhab.core.thing.Thing;
43 import org.openhab.core.thing.ThingStatus;
44 import org.openhab.core.thing.ThingStatusDetail;
45 import org.openhab.core.types.TimeSeries;
46 import org.slf4j.Logger;
47 import org.slf4j.LoggerFactory;
50 * The {@link ForecastWeatherHandler} is responsible for handling commands, which are
51 * sent to one of the channels.
53 * @author Sami Salonen - Initial contribution
56 public class ForecastWeatherHandler extends AbstractWeatherHandler {
58 private final Logger logger = LoggerFactory.getLogger(ForecastWeatherHandler.class);
60 private static final String GROUP_FORECAST_NOW = "forecastNow";
61 private static final int QUERY_RESOLUTION_MINUTES = 20; // The channel group hours should be divisible by this
62 // Hirlam horizon is 54h https://ilmatieteenlaitos.fi/avoin-data-avattavat-aineistot (in Finnish)
63 private static final int FORECAST_HORIZON_HOURS = 50; // should be divisible by QUERY_RESOLUTION_MINUTES
64 private static final Map<String, Map.Entry<String, @Nullable Unit<?>>> CHANNEL_TO_FORECAST_FIELD_NAME_AND_UNIT = new HashMap<>(
67 private static void addMapping(String channelId, String requestField, @Nullable Unit<?> unit) {
68 CHANNEL_TO_FORECAST_FIELD_NAME_AND_UNIT.put(channelId,
69 new AbstractMap.SimpleImmutableEntry<>(requestField, unit));
73 addMapping(CHANNEL_TEMPERATURE, PARAM_TEMPERATURE, CELSIUS);
74 addMapping(CHANNEL_HUMIDITY, PARAM_HUMIDITY, PERCENT);
75 addMapping(CHANNEL_WIND_DIRECTION, PARAM_WIND_DIRECTION, DEGREE_ANGLE);
76 addMapping(CHANNEL_WIND_SPEED, PARAM_WIND_SPEED, METRE_PER_SECOND);
77 addMapping(CHANNEL_GUST, PARAM_WIND_GUST, METRE_PER_SECOND);
78 addMapping(CHANNEL_PRESSURE, PARAM_PRESSURE, MILLIBAR);
79 addMapping(CHANNEL_PRECIPITATION_INTENSITY, PARAM_PRECIPITATION_1H, MILLIMETRE_PER_HOUR);
80 addMapping(CHANNEL_TOTAL_CLOUD_COVER, PARAM_TOTAL_CLOUD_COVER, PERCENT);
81 addMapping(CHANNEL_FORECAST_WEATHER_ID, PARAM_WEATHER_SYMBOL, null);
84 private @NonNullByDefault({}) LatLon location;
86 public ForecastWeatherHandler(Thing thing) {
88 // Override poll interval to slower value
89 pollIntervalSeconds = (int) TimeUnit.MINUTES.toSeconds(QUERY_RESOLUTION_MINUTES);
93 public void initialize() {
95 Object location = getConfig().get(BindingConstants.LOCATION);
96 if (location == null) {
97 logger.debug("Location not set for thing {} -- aborting initialization.", getThing().getUID());
98 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
99 String.format("location parameter not set"));
102 String latlon = location.toString();
103 String[] split = latlon.split(",");
104 if (split.length != 2) {
105 throw new NumberFormatException(String.format(
106 "Expecting location parameter to have latitude and longitude separated by comma (LATITUDE,LONGITUDE). Found %d values instead.",
109 this.location = new LatLon(new BigDecimal(split[0].trim()), new BigDecimal(split[1].trim()));
111 } catch (NumberFormatException e) {
112 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR, String.format(
113 "location parameter should be in format LATITUDE,LONGITUDE. Error details: %s", e.getMessage()));
118 public void dispose() {
120 this.location = null;
124 protected Request getRequest() {
125 long now = Instant.now().getEpochSecond();
126 return new ForecastRequest(location, floorToEvenMinutes(now, QUERY_RESOLUTION_MINUTES),
127 ceilToEvenMinutes(now + TimeUnit.HOURS.toSeconds(FORECAST_HORIZON_HOURS), QUERY_RESOLUTION_MINUTES),
128 QUERY_RESOLUTION_MINUTES);
132 protected void updateChannels() {
133 FMIResponse response = this.response;
134 if (response == null) {
138 Location location = unwrap(response.getLocations().stream().findFirst(),
139 "No locations in response -- no data? Aborting");
140 Map<String, String> properties = editProperties();
141 properties.put(PROP_NAME, location.name);
142 properties.put(PROP_LATITUDE, location.latitude.toPlainString());
143 properties.put(PROP_LONGITUDE, location.longitude.toPlainString());
144 updateProperties(properties);
145 updateHourlyChannels(response, location);
146 updateTimeSeriesChannels(response, location);
147 updateStatus(ThingStatus.ONLINE);
148 } catch (FMIUnexpectedResponseException e) {
149 // Unexpected (possibly bug) issue with response
150 logger.warn("Unexpected response encountered: {}", e.getMessage());
151 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
152 String.format("Unexpected API response: %s", e.getMessage()));
156 private void updateHourlyChannels(FMIResponse response, Location location) throws FMIUnexpectedResponseException {
157 for (Channel channel : getThing().getChannels()) {
158 ChannelUID channelUID = channel.getUID();
159 if (CHANNEL_GROUP_FORECAST.equals(channelUID.getGroupId())) {
160 // Skip time series group
163 int hours = getHours(channelUID);
164 int timeIndex = getTimeIndex(hours);
165 if (channelUID.getIdWithoutGroup().equals(CHANNEL_TIME)) {
166 // All parameters and locations should share the same timestamps. We use temperature to figure out
167 // timestamp for the group of channels
168 final String field = ForecastRequest.PARAM_TEMPERATURE;
169 Data data = unwrap(response.getData(location, field),
170 "Field %s not present for location %s in response. Bug?", field, location);
171 updateEpochSecondStateIfLinked(channelUID, data.timestampsEpochSecs[timeIndex]);
173 String field = getDataField(channelUID);
174 Unit<?> unit = getUnit(channelUID);
176 logger.error("Channel {} not handled. Bug?", channelUID.getId());
179 Data data = unwrap(response.getData(location, field),
180 "Field %s not present for location %s in response. Bug?", field, location);
181 updateStateIfLinked(channelUID, data.values[timeIndex], unit);
186 private void updateTimeSeriesChannels(FMIResponse response, Location location)
187 throws FMIUnexpectedResponseException {
188 for (Channel channel : getThing().getChannelsOfGroup(CHANNEL_GROUP_FORECAST)) {
189 ChannelUID channelUID = channel.getUID();
190 if (CHANNEL_TIME.equals(channelUID.getIdWithoutGroup())) {
191 // All parameters and locations should share the same timestamps. We use temperature to figure out
192 // timestamp for the group of channels
193 final String field = ForecastRequest.PARAM_TEMPERATURE;
194 Data data = unwrap(response.getData(location, field),
195 "Field %s not present for location %s in response. Bug?", field, location);
196 updateEpochSecondStateIfLinked(channelUID, data.timestampsEpochSecs[0]);
199 String field = getDataField(channelUID);
200 Unit<?> unit = getUnit(channelUID);
202 logger.error("Channel {} not handled. Bug?", channelUID.getId());
205 Data data = unwrap(response.getData(location, field),
206 "Field %s not present for location %s in response. Bug?", field, location);
207 if (data.values.length != data.timestampsEpochSecs.length) {
208 logger.warn("Number of values ({}) doesn't match number of timestamps ({})", data.values.length,
209 data.timestampsEpochSecs.length);
212 updateStateIfLinked(channelUID, data.values[0], unit);
213 TimeSeries timeSeries = new TimeSeries(REPLACE);
214 for (int i = 0; i < data.values.length; i++) {
215 timeSeries.add(Instant.ofEpochSecond(data.timestampsEpochSecs[i]), getState(data.values[i], unit));
217 sendTimeSeries(channelUID, timeSeries);
221 private static int getHours(ChannelUID uid) {
222 String groupId = uid.getGroupId();
223 if (groupId == null) {
224 throw new IllegalStateException("All channels should be in group!");
226 if (GROUP_FORECAST_NOW.equals(groupId)) {
229 return Integer.valueOf(groupId.substring(groupId.length() - 2));
233 private static int getTimeIndex(int hours) {
234 return (int) (TimeUnit.HOURS.toMinutes(hours) / QUERY_RESOLUTION_MINUTES);
237 @SuppressWarnings({ "unused", "null" })
238 private static @Nullable String getDataField(ChannelUID channelUID) {
239 Entry<String, @Nullable Unit<?>> entry = CHANNEL_TO_FORECAST_FIELD_NAME_AND_UNIT
240 .get(channelUID.getIdWithoutGroup());
244 return entry.getKey();
247 @SuppressWarnings({ "unused", "null" })
248 private static @Nullable Unit<?> getUnit(ChannelUID channelUID) {
249 Entry<String, @Nullable Unit<?>> entry = CHANNEL_TO_FORECAST_FIELD_NAME_AND_UNIT
250 .get(channelUID.getIdWithoutGroup());
254 return entry.getValue();