]> git.basschouten.com Git - openhab-addons.git/blob
03e78ef778b06e56744fb2cb8d48a5d856beb6a6
[openhab-addons.git] /
1 /**
2  * Copyright (c) 2010-2024 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.fmiweather.internal;
14
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;
20
21 import java.math.BigDecimal;
22 import java.time.Instant;
23 import java.util.AbstractMap;
24 import java.util.HashMap;
25 import java.util.Map;
26 import java.util.Map.Entry;
27 import java.util.concurrent.TimeUnit;
28
29 import javax.measure.Unit;
30
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;
48
49 /**
50  * The {@link ForecastWeatherHandler} is responsible for handling commands, which are
51  * sent to one of the channels.
52  *
53  * @author Sami Salonen - Initial contribution
54  */
55 @NonNullByDefault
56 public class ForecastWeatherHandler extends AbstractWeatherHandler {
57
58     private final Logger logger = LoggerFactory.getLogger(ForecastWeatherHandler.class);
59
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<>(
65             9);
66
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));
70     }
71
72     static {
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);
82     }
83
84     private @NonNullByDefault({}) LatLon location;
85
86     public ForecastWeatherHandler(Thing thing) {
87         super(thing);
88         // Override poll interval to slower value
89         pollIntervalSeconds = (int) TimeUnit.MINUTES.toSeconds(QUERY_RESOLUTION_MINUTES);
90     }
91
92     @Override
93     public void initialize() {
94         try {
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"));
100                 return;
101             }
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.",
107                         split.length));
108             }
109             this.location = new LatLon(new BigDecimal(split[0].trim()), new BigDecimal(split[1].trim()));
110             super.initialize();
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()));
114         }
115     }
116
117     @Override
118     public void dispose() {
119         super.dispose();
120         this.location = null;
121     }
122
123     @Override
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);
129     }
130
131     @Override
132     protected void updateChannels() {
133         FMIResponse response = this.response;
134         if (response == null) {
135             return;
136         }
137         try {
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()));
153         }
154     }
155
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
161                 continue;
162             }
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]);
172             } else {
173                 String field = getDataField(channelUID);
174                 Unit<?> unit = getUnit(channelUID);
175                 if (field == null) {
176                     logger.error("Channel {} not handled. Bug?", channelUID.getId());
177                     continue;
178                 }
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);
182             }
183         }
184     }
185
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]);
197                 continue;
198             }
199             String field = getDataField(channelUID);
200             Unit<?> unit = getUnit(channelUID);
201             if (field == null) {
202                 logger.error("Channel {} not handled. Bug?", channelUID.getId());
203                 continue;
204             }
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);
210                 continue;
211             }
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));
216             }
217             sendTimeSeries(channelUID, timeSeries);
218         }
219     }
220
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!");
225         }
226         if (GROUP_FORECAST_NOW.equals(groupId)) {
227             return 0;
228         } else {
229             return Integer.valueOf(groupId.substring(groupId.length() - 2));
230         }
231     }
232
233     private static int getTimeIndex(int hours) {
234         return (int) (TimeUnit.HOURS.toMinutes(hours) / QUERY_RESOLUTION_MINUTES);
235     }
236
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());
241         if (entry == null) {
242             return null;
243         }
244         return entry.getKey();
245     }
246
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());
251         if (entry == null) {
252             return null;
253         }
254         return entry.getValue();
255     }
256 }