]> git.basschouten.com Git - openhab-addons.git/blob
3aee7ede9825d42c6e0aa93540b2d0199cd9e7ea
[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
20 import java.math.BigDecimal;
21 import java.time.Instant;
22 import java.util.AbstractMap;
23 import java.util.HashMap;
24 import java.util.Map;
25 import java.util.Map.Entry;
26 import java.util.concurrent.TimeUnit;
27
28 import javax.measure.Unit;
29
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;
46
47 /**
48  * The {@link ForecastWeatherHandler} is responsible for handling commands, which are
49  * sent to one of the channels.
50  *
51  * @author Sami Salonen - Initial contribution
52  */
53 @NonNullByDefault
54 public class ForecastWeatherHandler extends AbstractWeatherHandler {
55
56     private final Logger logger = LoggerFactory.getLogger(ForecastWeatherHandler.class);
57
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<>(
63             9);
64
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));
68     }
69
70     static {
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);
80     }
81
82     private @NonNullByDefault({}) LatLon location;
83
84     public ForecastWeatherHandler(Thing thing) {
85         super(thing);
86         // Override poll interval to slower value
87         pollIntervalSeconds = (int) TimeUnit.MINUTES.toSeconds(QUERY_RESOLUTION_MINUTES);
88     }
89
90     @Override
91     public void initialize() {
92         try {
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"));
98                 return;
99             }
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.",
105                         split.length));
106             }
107             this.location = new LatLon(new BigDecimal(split[0].trim()), new BigDecimal(split[1].trim()));
108             super.initialize();
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()));
112         }
113     }
114
115     @Override
116     public void dispose() {
117         super.dispose();
118         this.location = null;
119     }
120
121     @Override
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);
127     }
128
129     @Override
130     protected void updateChannels() {
131         FMIResponse response = this.response;
132         if (response == null) {
133             return;
134         }
135         try {
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]);
154                 } else {
155                     String field = getDataField(channelUID);
156                     Unit<?> unit = getUnit(channelUID);
157                     if (field == null) {
158                         logger.error("Channel {} not handled. Bug?", channelUID.getId());
159                         continue;
160                     }
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);
164                 }
165             }
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()));
172         }
173     }
174
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!");
179         }
180         if (GROUP_FORECAST_NOW.equals(groupId)) {
181             return 0;
182         } else {
183             return Integer.valueOf(groupId.substring(groupId.length() - 2));
184         }
185     }
186
187     private static int getTimeIndex(int hours) {
188         return (int) (TimeUnit.HOURS.toMinutes(hours) / QUERY_RESOLUTION_MINUTES);
189     }
190
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());
195         if (entry == null) {
196             return null;
197         }
198         return entry.getKey();
199     }
200
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());
205         if (entry == null) {
206             return null;
207         }
208         return entry.getValue();
209     }
210 }