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.client;
15 import java.io.IOException;
16 import java.io.StringReader;
17 import java.math.BigDecimal;
18 import java.util.HashMap;
19 import java.util.HashSet;
20 import java.util.Iterator;
23 import java.util.stream.IntStream;
25 import javax.xml.namespace.NamespaceContext;
26 import javax.xml.parsers.DocumentBuilder;
27 import javax.xml.parsers.DocumentBuilderFactory;
28 import javax.xml.parsers.ParserConfigurationException;
29 import javax.xml.xpath.XPath;
30 import javax.xml.xpath.XPathConstants;
31 import javax.xml.xpath.XPathExpression;
32 import javax.xml.xpath.XPathExpressionException;
33 import javax.xml.xpath.XPathFactory;
35 import org.eclipse.jdt.annotation.NonNullByDefault;
36 import org.eclipse.jdt.annotation.Nullable;
37 import org.openhab.binding.fmiweather.internal.client.FMIResponse.Builder;
38 import org.openhab.binding.fmiweather.internal.client.exception.FMIExceptionReportException;
39 import org.openhab.binding.fmiweather.internal.client.exception.FMIIOException;
40 import org.openhab.binding.fmiweather.internal.client.exception.FMIUnexpectedResponseException;
41 import org.openhab.core.io.net.http.HttpUtil;
42 import org.slf4j.Logger;
43 import org.slf4j.LoggerFactory;
44 import org.w3c.dom.Document;
45 import org.w3c.dom.Node;
46 import org.w3c.dom.NodeList;
47 import org.xml.sax.InputSource;
48 import org.xml.sax.SAXException;
52 * Client for accessing FMI weather data
54 * Subject to license terms https://en.ilmatieteenlaitos.fi/open-data
57 * All weather stations:
58 * https://opendata.fmi.fi/wfs/fin?service=WFS&version=2.0.0&request=GetFeature&storedquery_id=fmi::ef::stations&networkid=121&
59 * Networkid parameter isexplained in entries of
60 * https://opendata.fmi.fi/wfs/fin?service=WFS&version=2.0.0&request=GetFeature&storedquery_id=fmi::ef::stations
62 * @author Sami Salonen - Initial contribution
68 private final Logger logger = LoggerFactory.getLogger(Client.class);
70 public static final String WEATHER_STATIONS_URL = "https://opendata.fmi.fi/wfs/fin?service=WFS&version=2.0.0&request=GetFeature&storedquery_id=fmi::ef::stations&networkid=121&";
72 private static final Map<String, String> NAMESPACES = new HashMap<>();
74 NAMESPACES.put("target", "http://xml.fmi.fi/namespace/om/atmosphericfeatures/1.1");
75 NAMESPACES.put("gml", "http://www.opengis.net/gml/3.2");
76 NAMESPACES.put("xlink", "http://www.w3.org/1999/xlink");
77 NAMESPACES.put("ows", "http://www.opengis.net/ows/1.1");
78 NAMESPACES.put("gmlcov", "http://www.opengis.net/gmlcov/1.0");
79 NAMESPACES.put("swe", "http://www.opengis.net/swe/2.0");
81 NAMESPACES.put("wfs", "http://www.opengis.net/wfs/2.0");
82 NAMESPACES.put("ef", "http://inspire.ec.europa.eu/schemas/ef/4.0");
84 private static final NamespaceContext NAMESPACE_CONTEXT = new NamespaceContext() {
86 public @Nullable String getNamespaceURI(@Nullable String prefix) {
87 return NAMESPACES.get(prefix);
90 @SuppressWarnings("rawtypes")
92 public @Nullable Iterator getPrefixes(@Nullable String val) {
97 public @Nullable String getPrefix(@Nullable String uri) {
102 private DocumentBuilderFactory documentBuilderFactory = DocumentBuilderFactory.newInstance();
103 private DocumentBuilder documentBuilder;
106 documentBuilderFactory.setNamespaceAware(true);
108 // see https://cheatsheetseries.owasp.org/cheatsheets/XML_External_Entity_Prevention_Cheat_Sheet.html
109 documentBuilderFactory.setFeature("http://xml.org/sax/features/external-general-entities", false);
110 documentBuilderFactory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
111 documentBuilderFactory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
112 documentBuilderFactory.setXIncludeAware(false);
113 documentBuilderFactory.setExpandEntityReferences(false);
114 documentBuilder = documentBuilderFactory.newDocumentBuilder();
115 } catch (ParserConfigurationException e) {
116 throw new IllegalStateException(e);
121 * Query request and return the data
123 * @param request request to process
124 * @param timeoutMillis timeout for the http call
125 * @return data corresponding to the query
126 * @throws FMIIOException on all I/O errors
127 * @throws FMIUnexpectedResponseException on all unexpected content errors
128 * @throw FMIExceptionReportException on explicit error responses from the server
130 public FMIResponse query(Request request, int timeoutMillis)
131 throws FMIExceptionReportException, FMIUnexpectedResponseException, FMIIOException {
133 String url = request.toUrl();
134 String responseText = HttpUtil.executeUrl("GET", url, timeoutMillis);
135 if (responseText == null) {
136 throw new FMIIOException(String.format("HTTP error with %s", request.toUrl()));
138 FMIResponse response = parseMultiPointCoverageXml(responseText);
139 logger.debug("Request {} translated to url {}. Response: {}", request, url, response);
141 } catch (IOException e) {
142 throw new FMIIOException(e);
143 } catch (SAXException | XPathExpressionException e) {
144 throw new FMIUnexpectedResponseException(e);
149 * Query all weather stations
151 * @param timeoutMillis timeout for the http call
152 * @return locations representing stations
153 * @throws FMIIOException on all I/O errors
154 * @throws FMIUnexpectedResponseException on all unexpected content errors
155 * @throw FMIExceptionReportException on explicit error responses from the server
157 public Set<Location> queryWeatherStations(int timeoutMillis)
158 throws FMIIOException, FMIUnexpectedResponseException, FMIExceptionReportException {
160 String response = HttpUtil.executeUrl("GET", WEATHER_STATIONS_URL, timeoutMillis);
161 if (response == null) {
162 throw new FMIIOException(String.format("HTTP error with %s", WEATHER_STATIONS_URL));
164 return parseStations(response);
165 } catch (IOException e) {
166 throw new FMIIOException(e);
167 } catch (XPathExpressionException | SAXException e) {
168 throw new FMIUnexpectedResponseException(e);
172 private Set<Location> parseStations(String response) throws FMIExceptionReportException,
173 FMIUnexpectedResponseException, SAXException, IOException, XPathExpressionException {
174 Document document = documentBuilder.parse(new InputSource(new StringReader(response)));
176 XPath xPath = XPathFactory.newInstance().newXPath();
177 xPath.setNamespaceContext(NAMESPACE_CONTEXT);
179 boolean isExceptionReport = ((Node) xPath.compile("/ows:ExceptionReport").evaluate(document,
180 XPathConstants.NODE)) != null;
181 if (isExceptionReport) {
182 Node exceptionCode = (Node) xPath.compile("/ows:ExceptionReport/ows:Exception/@exceptionCode")
183 .evaluate(document, XPathConstants.NODE);
184 String[] exceptionText = queryNodeValues(xPath.compile("//ows:ExceptionText/text()"), document);
185 throw new FMIExceptionReportException(exceptionCode.getNodeValue(), exceptionText);
188 String[] fmisids = queryNodeValues(
190 "/wfs:FeatureCollection/wfs:member/ef:EnvironmentalMonitoringFacility/gml:identifier/text()"),
192 String[] names = queryNodeValues(xPath.compile(
193 "/wfs:FeatureCollection/wfs:member/ef:EnvironmentalMonitoringFacility/gml:name[@codeSpace='http://xml.fmi.fi/namespace/locationcode/name']/text()"),
195 String[] representativePoints = queryNodeValues(xPath.compile(
196 "/wfs:FeatureCollection/wfs:member/ef:EnvironmentalMonitoringFacility/ef:representativePoint/gml:Point/gml:pos/text()"),
199 if (fmisids.length != names.length || fmisids.length != representativePoints.length) {
200 throw new FMIUnexpectedResponseException(String.format(
201 "Could not all properties of locations: fmisids: %d, names: %d, representativePoints: %d",
202 fmisids.length, names.length, representativePoints.length));
205 Set<Location> locations = new HashSet<>(representativePoints.length);
206 for (int i = 0; i < representativePoints.length; i++) {
207 BigDecimal[] latlon = parseLatLon(representativePoints[i]);
208 locations.add(new Location(names[i], fmisids[i], latlon[0], latlon[1]));
214 * Parse FMI multipointcoverage formatted xml response
217 private FMIResponse parseMultiPointCoverageXml(String response) throws FMIUnexpectedResponseException,
218 FMIExceptionReportException, SAXException, IOException, XPathExpressionException {
219 Document document = documentBuilder.parse(new InputSource(new StringReader(response)));
221 XPath xPath = XPathFactory.newInstance().newXPath();
222 xPath.setNamespaceContext(NAMESPACE_CONTEXT);
224 boolean isExceptionReport = ((Node) xPath.compile("/ows:ExceptionReport").evaluate(document,
225 XPathConstants.NODE)) != null;
226 if (isExceptionReport) {
227 Node exceptionCode = (Node) xPath.compile("/ows:ExceptionReport/ows:Exception/@exceptionCode")
228 .evaluate(document, XPathConstants.NODE);
229 String[] exceptionText = queryNodeValues(xPath.compile("//ows:ExceptionText/text()"), document);
230 throw new FMIExceptionReportException(exceptionCode.getNodeValue(), exceptionText);
233 Builder builder = new FMIResponse.Builder();
235 String[] parameters = queryNodeValues(xPath.compile("//swe:field/@name"), document);
237 * Observations have FMISID (FMI Station ID?), with forecasts we use lat & lon
239 String[] ids = queryNodeValues(xPath.compile(
240 "//target:Location/gml:identifier[@codeSpace='http://xml.fmi.fi/namespace/stationcode/fmisid']/text()"),
243 String[] names = queryNodeValues(xPath.compile(
244 "//target:Location/gml:name[@codeSpace='http://xml.fmi.fi/namespace/locationcode/name']/text()"),
246 String[] representativePointRefs = queryNodeValues(
247 xPath.compile("//target:Location/target:representativePoint/@xlink:href"), document);
249 if ((ids.length > 0 && ids.length != names.length) || names.length != representativePointRefs.length) {
250 throw new FMIUnexpectedResponseException(String.format(
251 "Could not all properties of locations: ids: %d, names: %d, representativePointRefs: %d",
252 ids.length, names.length, representativePointRefs.length));
255 Location[] locations = new Location[representativePointRefs.length];
256 for (int i = 0; i < locations.length; i++) {
257 BigDecimal[] latlon = findLatLon(xPath, i, document, representativePointRefs[i]);
258 String id = ids.length == 0 ? String.format("%s,%s", latlon[0].toPlainString(), latlon[1].toPlainString())
260 locations[i] = new Location(names[i], id, latlon[0], latlon[1]);
263 logger.trace("names ({}): {}", names.length, names);
264 logger.trace("parameters ({}): {}", parameters.length, parameters);
265 if (names.length == 0) {
266 // No data, e.g. when starttime=endtime
267 return builder.build();
270 String latLonTimeTripletText = takeFirstOrError("positions",
271 queryNodeValues(xPath.compile("//gmlcov:positions/text()"), document));
272 String[] latLonTimeTripletEntries = latLonTimeTripletText.trim().split("\\s+");
273 logger.trace("latLonTimeTripletText: {}", latLonTimeTripletText);
274 logger.trace("latLonTimeTripletEntries ({}): {}", latLonTimeTripletEntries.length, latLonTimeTripletEntries);
275 int countTimestamps = latLonTimeTripletEntries.length / 3 / locations.length;
276 long[] timestampsEpoch = IntStream.range(0, latLonTimeTripletEntries.length).filter(i -> i % 3 == 0)
277 .limit(countTimestamps).mapToLong(i -> Long.parseLong(latLonTimeTripletEntries[i + 2])).toArray();
279 assert countTimestamps == timestampsEpoch.length;
280 logger.trace("countTimestamps ({}): {}", countTimestamps, timestampsEpoch);
281 validatePositionEntries(locations, timestampsEpoch, latLonTimeTripletEntries);
283 String valuesText = takeFirstOrError("doubleOrNilReasonTupleList",
284 queryNodeValues(xPath.compile(".//gml:doubleOrNilReasonTupleList/text()"), document));
285 String[] valuesEntries = valuesText.trim().split("\\s+");
286 logger.trace("valuesText: {}", valuesText);
287 logger.trace("valuesEntries ({}): {}", valuesEntries.length, valuesEntries);
288 if (valuesEntries.length != locations.length * parameters.length * countTimestamps) {
289 throw new FMIUnexpectedResponseException(String.format(
290 "Wrong number of values (%d). Expecting %d * %d * %d = %d", valuesEntries.length, locations.length,
291 parameters.length, countTimestamps, countTimestamps * locations.length * parameters.length));
293 IntStream.range(0, locations.length).forEach(locationIndex -> {
294 for (int parameterIndex = 0; parameterIndex < parameters.length; parameterIndex++) {
295 for (int timestepIndex = 0; timestepIndex < countTimestamps; timestepIndex++) {
296 BigDecimal val = toBigDecimalOrNullIfNaN(
297 valuesEntries[locationIndex * countTimestamps * parameters.length
298 + timestepIndex * parameters.length + parameterIndex]);
299 logger.trace("Found value {}={} @ time={} for location {}", parameters[parameterIndex], val,
300 timestampsEpoch[timestepIndex], locations[locationIndex].id);
301 builder.appendLocationData(locations[locationIndex], countTimestamps, parameters[parameterIndex],
302 timestampsEpoch[timestepIndex], val);
307 return builder.build();
311 * Find representative latitude and longitude matching given xlink href attribute value
313 * @param xPath xpath object used for query
314 * @param entryIndex index of the location, for logging only on errors
315 * @param document document object
316 * @param href xlink href attribute value. Should start with #
317 * @return latitude and longitude values as array
318 * @throws FMIUnexpectedResponseException parsing errors or when entry is not found
319 * @throws XPathExpressionException xpath errors
321 private BigDecimal[] findLatLon(XPath xPath, int entryIndex, Document document, String href)
322 throws FMIUnexpectedResponseException, XPathExpressionException {
323 if (!href.startsWith("#")) {
324 throw new FMIUnexpectedResponseException(
325 "Could not find valid representativePoint xlink:href, does not start with #");
327 String pointId = href.substring(1);
328 String pointLatLon = takeFirstOrError(String.format("[%d]/pos", entryIndex),
329 queryNodeValues(xPath.compile(".//gml:Point[@gml:id='" + pointId + "']/gml:pos/text()"), document));
330 return parseLatLon(pointLatLon);
334 * Parse string reprsenting latitude longitude string separated by space
336 * @param pointLatLon latitude longitude string separated by space
337 * @return latitude and longitude values as array
338 * @throws FMIUnexpectedResponseException on parsing errors
340 private BigDecimal[] parseLatLon(String pointLatLon) throws FMIUnexpectedResponseException {
341 String[] latlon = pointLatLon.split(" ");
343 if (latlon.length != 2) {
344 throw new FMIUnexpectedResponseException(String.format(
345 "Invalid latitude or longitude format, expected two values separated by space, got %d values: '%s'",
346 latlon.length, latlon));
349 lat = new BigDecimal(latlon[0]);
350 lon = new BigDecimal(latlon[1]);
351 } catch (NumberFormatException e) {
352 throw new FMIUnexpectedResponseException(
353 String.format("Invalid latitude or longitude format: %s", e.getMessage()));
355 return new BigDecimal[] { lat, lon };
358 private String[] queryNodeValues(XPathExpression expression, Object source) throws XPathExpressionException {
359 NodeList nodeList = (NodeList) expression.evaluate(source, XPathConstants.NODESET);
360 String[] values = new String[nodeList.getLength()];
361 for (int i = 0; i < nodeList.getLength(); i++) {
362 values[i] = nodeList.item(i).getNodeValue();
368 * Asserts that length of values is exactly 1, and returns it
370 * @param errorDescription error description for FMIResponseException
373 * @throws FMIUnexpectedResponseException when length of values != 1
375 private String takeFirstOrError(String errorDescription, String[] values) throws FMIUnexpectedResponseException {
376 if (values.length != 1) {
377 throw new FMIUnexpectedResponseException(
378 String.format("No unique match found: %s (found %d)", errorDescription, values.length));
384 * Convert string to BigDecimal. "NaN" string is converted to null
387 * @return null when value is "NaN". Otherwise BigDecimal representing the string
389 private @Nullable BigDecimal toBigDecimalOrNullIfNaN(String value) {
390 if ("NaN".equals(value)) {
393 return new BigDecimal(value);
398 * Validate ordering and values of gmlcov:positions (latLonTimeTripletEntries)
400 * pos1_lat, pos1_lon, time1
401 * pos1_lat, pos1_lon, time2
402 * pos1_lat, pos1_lon, time3
403 * pos2_lat, pos2_lon, time1
404 * pos2_lat, pos2_lon, time2
407 * - lat, lon should be in correct order and match position entries ("locations")
408 * - time should values should be exactly same for each point (above time1, time2, ...), and match given timestamps
409 * ("timestampsEpoch")
412 * @param locations previously discovered locations
413 * @param timestampsEpoch expected timestamps
414 * @param latLonTimeTripletEntries flat array of strings representing the array, [row1_cell1, row1_cell2,
416 * @throws FMIUnexpectedResponseException when value ordering is not matching the expected
418 private void validatePositionEntries(Location[] locations, long[] timestampsEpoch,
419 String[] latLonTimeTripletEntries) throws FMIUnexpectedResponseException {
420 int countTimestamps = timestampsEpoch.length;
421 for (int locationIndex = 0; locationIndex < locations.length; locationIndex++) {
422 String firstLat = latLonTimeTripletEntries[locationIndex * countTimestamps * 3];
423 String fistLon = latLonTimeTripletEntries[locationIndex * countTimestamps * 3 + 1];
425 // step through entries for this position
426 for (int timestepIndex = 0; timestepIndex < countTimestamps; timestepIndex++) {
427 String lat = latLonTimeTripletEntries[locationIndex * countTimestamps * 3 + timestepIndex * 3];
428 String lon = latLonTimeTripletEntries[locationIndex * countTimestamps * 3 + timestepIndex * 3 + 1];
429 String timeEpochSec = latLonTimeTripletEntries[locationIndex * countTimestamps * 3 + timestepIndex * 3
431 if (!lat.equals(firstLat) || !lon.equals(fistLon)) {
432 throw new FMIUnexpectedResponseException(String.format(
433 "positions[%d] lat, lon for time index [%d] was not matching expected ordering",
434 locationIndex, timestepIndex));
436 String expectedLat = locations[locationIndex].latitude.toPlainString();
437 String expectedLon = locations[locationIndex].longitude.toPlainString();
438 if (!lat.equals(expectedLat) || !lon.equals(expectedLon)) {
439 throw new FMIUnexpectedResponseException(String.format(
440 "positions[%d] lat, lon for time index [%d] was not matching representativePoint",
441 locationIndex, timestepIndex));
444 if (Long.parseLong(timeEpochSec) != timestampsEpoch[timestepIndex]) {
445 throw new FMIUnexpectedResponseException(String.format(
446 "positions[%d] time (%s) for time index [%d] was not matching expected (%d) ordering",
447 locationIndex, timeEpochSec, timestepIndex, timestampsEpoch[timestepIndex]));