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.fmiweather.internal;
15 import static org.openhab.binding.fmiweather.internal.BindingConstants.*;
16 import static org.openhab.binding.fmiweather.internal.client.ObservationRequest.*;
17 import static org.openhab.core.library.unit.SIUnits.*;
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.Objects;
27 import java.util.concurrent.TimeUnit;
28 import java.util.function.Function;
30 import javax.measure.Unit;
31 import javax.measure.quantity.Length;
33 import org.eclipse.jdt.annotation.NonNullByDefault;
34 import org.eclipse.jdt.annotation.Nullable;
35 import org.openhab.binding.fmiweather.internal.client.Data;
36 import org.openhab.binding.fmiweather.internal.client.FMIResponse;
37 import org.openhab.binding.fmiweather.internal.client.FMISID;
38 import org.openhab.binding.fmiweather.internal.client.Location;
39 import org.openhab.binding.fmiweather.internal.client.ObservationRequest;
40 import org.openhab.binding.fmiweather.internal.client.Request;
41 import org.openhab.binding.fmiweather.internal.client.exception.FMIUnexpectedResponseException;
42 import org.openhab.core.library.unit.MetricPrefix;
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.types.UnDefType;
49 import org.slf4j.Logger;
50 import org.slf4j.LoggerFactory;
53 * The {@link ObservationWeatherHandler} is responsible for handling commands, which are
54 * sent to one of the channels.
56 * @author Sami Salonen - Initial contribution
59 public class ObservationWeatherHandler extends AbstractWeatherHandler {
61 private final Logger logger = LoggerFactory.getLogger(ObservationWeatherHandler.class);
62 private static final long OBSERVATION_LOOK_BACK_SECONDS = TimeUnit.MINUTES.toSeconds(30);
63 private static final int STEP_MINUTES = 10;
64 private static final int POLL_INTERVAL_SECONDS = 600;
65 private static BigDecimal HUNDRED = BigDecimal.valueOf(100);
66 private static BigDecimal NA_CLOUD_MAX = BigDecimal.valueOf(8); // API value when having full clouds (overcast)
67 private static BigDecimal NA_CLOUD_COVERAGE = BigDecimal.valueOf(9); // API value when cloud coverage could not be
70 public static final Unit<Length> MILLIMETRE = MetricPrefix.MILLI(METRE);
71 public static final Unit<Length> CENTIMETRE = MetricPrefix.CENTI(METRE);
73 private static final Map<String, Map.Entry<String, @Nullable Unit<?>>> CHANNEL_TO_OBSERVATION_FIELD_NAME_AND_UNIT = new HashMap<>(
75 private static final Map<String, @Nullable Function<BigDecimal, @Nullable BigDecimal>> OBSERVATION_FIELD_NAME_TO_CONVERSION_FUNC = new HashMap<>(
78 private static void addMapping(String channelId, String requestField, @Nullable Unit<?> result_unit,
79 @Nullable Function<BigDecimal, @Nullable BigDecimal> conversion) {
80 CHANNEL_TO_OBSERVATION_FIELD_NAME_AND_UNIT.put(channelId,
81 new AbstractMap.SimpleImmutableEntry<>(requestField, result_unit));
82 OBSERVATION_FIELD_NAME_TO_CONVERSION_FUNC.put(requestField, conversion);
86 addMapping(CHANNEL_TEMPERATURE, PARAM_TEMPERATURE, CELSIUS, null);
87 addMapping(CHANNEL_HUMIDITY, PARAM_HUMIDITY, PERCENT, null);
88 addMapping(CHANNEL_WIND_DIRECTION, PARAM_WIND_DIRECTION, DEGREE_ANGLE, null);
89 addMapping(CHANNEL_WIND_SPEED, PARAM_WIND_SPEED, METRE_PER_SECOND, null);
90 addMapping(CHANNEL_GUST, PARAM_WIND_GUST, METRE_PER_SECOND, null);
91 addMapping(CHANNEL_PRESSURE, PARAM_PRESSURE, MILLIBAR, null);
92 addMapping(CHANNEL_PRECIPITATION_AMOUNT, PARAM_PRECIPITATION_AMOUNT, MILLIMETRE, null);
93 addMapping(CHANNEL_SNOW_DEPTH, PARAM_SNOW_DEPTH, CENTIMETRE, null);
94 addMapping(CHANNEL_VISIBILITY, PARAM_VISIBILITY, METRE, null);
95 // Converting 0...8 scale to percentage 0...100%. Value of 9 is converted to null/UNDEF
96 addMapping(CHANNEL_CLOUDS, PARAM_CLOUDS, PERCENT, clouds -> clouds.compareTo(NA_CLOUD_COVERAGE) == 0 ? null
97 : clouds.divide(NA_CLOUD_MAX).multiply(HUNDRED));
98 addMapping(CHANNEL_OBSERVATION_PRESENT_WEATHER, PARAM_PRESENT_WEATHER, null, null);
101 private @NonNullByDefault({}) String fmisid;
103 public ObservationWeatherHandler(Thing thing) {
105 pollIntervalSeconds = POLL_INTERVAL_SECONDS;
109 public void initialize() {
110 fmisid = Objects.toString(getConfig().get(BindingConstants.FMISID), null);
111 if (fmisid == null) {
112 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.CONFIGURATION_ERROR,
113 String.format("%s parameter not set", FMISID));
120 protected Request getRequest() {
121 long now = Instant.now().getEpochSecond();
122 return new ObservationRequest(new FMISID(fmisid),
123 floorToEvenMinutes(now - OBSERVATION_LOOK_BACK_SECONDS, STEP_MINUTES),
124 ceilToEvenMinutes(now, STEP_MINUTES), STEP_MINUTES);
128 protected void updateChannels() {
129 FMIResponse response = this.response;
130 if (response == null) {
134 Location location = unwrap(response.getLocations().stream().findFirst(),
135 "No locations in response -- no data? Aborting");
136 Map<String, String> properties = editProperties();
137 properties.put(PROP_NAME, location.name);
138 properties.put(PROP_LATITUDE, location.latitude.toPlainString());
139 properties.put(PROP_LONGITUDE, location.longitude.toPlainString());
140 updateProperties(properties);
141 // All parameters and locations should share the same timestamps. We use temperature to figure out most
142 // recent timestamp which has non-NaN value
143 int lastValidIndex = unwrap(
144 response.getData(location, ObservationRequest.PARAM_TEMPERATURE).map(data -> lastValidIndex(data)),
145 "lastValidIndex not available. Bug?");
146 for (Channel channel : getThing().getChannels()) {
147 ChannelUID channelUID = channel.getUID();
148 if (lastValidIndex < 0) {
149 updateState(channelUID, UnDefType.UNDEF);
150 } else if (channelUID.getIdWithoutGroup().equals(CHANNEL_TIME)) {
151 String field = ObservationRequest.PARAM_TEMPERATURE;
152 Data data = unwrap(response.getData(location, field),
153 "Field %s not present for location %s in response. Bug?", field, location);
154 updateEpochSecondStateIfLinked(channelUID, data.timestampsEpochSecs[lastValidIndex]);
156 String field = getDataField(channelUID);
157 Unit<?> unit = getUnit(channelUID);
159 logger.error("Channel {} not handled. Bug?", channelUID.getId());
162 Data data = unwrap(response.getData(location, field),
163 "Field %s not present for location % in response. Bug?", field, location);
164 BigDecimal rawValue = data.values[lastValidIndex];
165 BigDecimal processedValue = preprocess(field, rawValue);
166 updateStateIfLinked(channelUID, processedValue, unit);
169 updateStatus(ThingStatus.ONLINE);
170 } catch (FMIUnexpectedResponseException e) {
171 // Unexpected (possibly bug) issue with response
172 logger.warn("Unexpected response encountered: {}", e.getMessage());
173 updateStatus(ThingStatus.OFFLINE, ThingStatusDetail.COMMUNICATION_ERROR,
174 String.format("Unexpected API response: %s", e.getMessage()));
178 @SuppressWarnings({ "null", "unused" })
179 private static @Nullable String getDataField(ChannelUID channelUID) {
180 Entry<String, @Nullable Unit<?>> entry = CHANNEL_TO_OBSERVATION_FIELD_NAME_AND_UNIT
181 .get(channelUID.getIdWithoutGroup());
185 return entry.getKey();
188 @SuppressWarnings({ "null", "unused" })
189 private static @Nullable Unit<?> getUnit(ChannelUID channelUID) {
190 Entry<String, @Nullable Unit<?>> entry = CHANNEL_TO_OBSERVATION_FIELD_NAME_AND_UNIT
191 .get(channelUID.getIdWithoutGroup());
195 return entry.getValue();
198 private static @Nullable BigDecimal preprocess(String fieldName, @Nullable BigDecimal rawValue) {
199 if (rawValue == null) {
202 Function<BigDecimal, @Nullable BigDecimal> func = OBSERVATION_FIELD_NAME_TO_CONVERSION_FUNC.get(fieldName);
204 // No conversion required
207 return func.apply(rawValue);