/* ================================================================== * BasicWeatherUndergoundClient.java - 7/04/2017 4:32:46 PM * * Copyright 2017 SolarNetwork.net Dev Team * * This program is free software; you can redistribute it and/or * modify it under the terms of the GNU General Public License as * published by the Free Software Foundation; either version 2 of * the License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU * General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program; if not, write to the Free Software * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA * 02111-1307 USA * ================================================================== */ package net.solarnetwork.node.weather.wu; import static net.solarnetwork.util.JsonNodeUtils.parseBigDecimalAttribute; import static net.solarnetwork.util.JsonNodeUtils.parseIntegerAttribute; import static net.solarnetwork.util.JsonNodeUtils.parseLongAttribute; import static net.solarnetwork.util.JsonNodeUtils.parseStringAttribute; import java.io.IOException; import java.io.UnsupportedEncodingException; import java.math.BigDecimal; import java.math.RoundingMode; import java.net.URLConnection; import java.net.URLEncoder; import java.util.ArrayList; import java.util.Collection; import java.util.Collections; import java.util.Date; import org.joda.time.DateTimeZone; import org.joda.time.LocalDate; import org.joda.time.LocalTime; import org.springframework.util.StringUtils; import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; import net.solarnetwork.node.domain.AtmosphericDatum; import net.solarnetwork.node.domain.DayDatum; import net.solarnetwork.node.domain.GeneralAtmosphericDatum; import net.solarnetwork.node.domain.GeneralDayDatum; import net.solarnetwork.support.HttpClientSupport; /** * Basic implementation of {@link WeatherUndergroundClient}. * * @author matt * @version 1.0 */ public class BasicWeatherUndergoundClient extends HttpClientSupport implements WeatherUndergroundClient { /** The default value for the {@code baseUrl} property. */ public static final String DEFAULT_API_BASE_URL = "http://api.wunderground.com/api"; /** The default value for the {@code baseAutocompleteUrl} property. */ public static final String DEFAULT_AUTOCOMPLETE_BASE_URL = "http://autocomplete.wunderground.com/aq"; private String apiKey; private String baseUrl = DEFAULT_API_BASE_URL; private String baseAutocompleteUrl = DEFAULT_AUTOCOMPLETE_BASE_URL; private ObjectMapper objectMapper = new ObjectMapper(); public BasicWeatherUndergoundClient() { super(); } private String urlForActionPath(String action, String path) { return baseUrl + '/' + apiKey + '/' + action + path; } private String urlForActionsPath(String[] actions, String path) { return baseUrl + '/' + apiKey + '/' + StringUtils.arrayToDelimitedString(actions, "/") + path; } private String urlForAutocomplete(String query, String country) { StringBuilder buf = new StringBuilder(baseAutocompleteUrl); buf.append("?query="); try { if ( query != null ) { buf.append(URLEncoder.encode(query, "UTF-8")); } if ( country != null && country.length() > 0 ) { buf.append("&c=").append(URLEncoder.encode(country, "UTF-8")); } } catch ( UnsupportedEncodingException e ) { // should not get here ever } return buf.toString(); } @Override public Collection<WeatherUndergroundLocation> findLocationsForIpAddress() { final String url = urlForActionPath("geolookup", "/q/autoip.json"); Collection<WeatherUndergroundLocation> results = new ArrayList<WeatherUndergroundLocation>(); try { URLConnection conn = getURLConnection(url, HTTP_METHOD_GET); JsonNode data = objectMapper.readTree(getInputStreamFromURLConnection(conn)); JsonNode locNode = data.get("location"); if ( locNode != null ) { BasicWeatherUndergroundLocation loc = parseLocation(locNode); if ( loc != null ) { results.add(loc); } } } catch ( IOException e ) { log.warn("Error reading Weather Underground URL [{}]: {}", url, e.getMessage()); } return results; } @Override public Collection<WeatherUndergroundLocation> findLocations(String name, String country) { final String url = urlForAutocomplete(name, country); Collection<WeatherUndergroundLocation> results = new ArrayList<WeatherUndergroundLocation>(); try { URLConnection conn = getURLConnection(url, HTTP_METHOD_GET); JsonNode data = objectMapper.readTree(getInputStreamFromURLConnection(conn)); JsonNode locArrayNode = data.get("RESULTS"); if ( locArrayNode != null && locArrayNode.isArray() ) { for ( JsonNode locNode : locArrayNode ) { BasicWeatherUndergroundLocation loc = parseLocation(locNode); if ( loc != null ) { results.add(loc); } } } } catch ( IOException e ) { log.warn("Error reading Weather Underground URL [{}]: {}", url, e.getMessage()); } return results; } @Override public AtmosphericDatum getCurrentConditions(String identifier) { if ( identifier == null ) { return null; } final String url = urlForActionPath("conditions", identifier + ".json"); GeneralAtmosphericDatum result = null; try { URLConnection conn = getURLConnection(url, HTTP_METHOD_GET); JsonNode data = objectMapper.readTree(getInputStreamFromURLConnection(conn)); JsonNode conditionsNode = data.get("current_observation"); result = parseConditions(conditionsNode); } catch ( IOException e ) { log.warn("Error reading Weather Underground URL [{}]: {}", url, e.getMessage()); } return result; } @Override public Collection<AtmosphericDatum> getHourlyForecast(String identifier) { if ( identifier == null ) { return null; } final String url = urlForActionPath("hourly", identifier + ".json"); Collection<AtmosphericDatum> results = new ArrayList<AtmosphericDatum>(); try { URLConnection conn = getURLConnection(url, HTTP_METHOD_GET); JsonNode data = objectMapper.readTree(getInputStreamFromURLConnection(conn)); JsonNode datumArrayNode = data.get("hourly_forecast"); if ( datumArrayNode != null && datumArrayNode.isArray() ) { for ( JsonNode datumNode : datumArrayNode ) { GeneralAtmosphericDatum datum = parseHourlyForecast(datumNode); if ( datum != null ) { results.add(datum); } } } } catch ( IOException e ) { log.warn("Error reading Weather Underground URL [{}]: {}", url, e.getMessage()); } return results; } @Override public DayDatum getCurrentDay(String identifier) { if ( identifier == null ) { return null; } final String url = urlForActionsPath(new String[] { "astronomy", "forecast" }, identifier + ".json"); GeneralDayDatum result = null; try { URLConnection conn = getURLConnection(url, HTTP_METHOD_GET); JsonNode data = objectMapper.readTree(getInputStreamFromURLConnection(conn)); result = parseDay(data); } catch ( IOException e ) { log.warn("Error reading Weather Underground URL [{}]: {}", url, e.getMessage()); } return result; } @Override public Collection<DayDatum> getThreeDayForecast(String identifier) { if ( identifier == null ) { return null; } final String url = urlForActionPath("forecast", identifier + ".json"); Collection<DayDatum> results = Collections.emptyList(); try { URLConnection conn = getURLConnection(url, HTTP_METHOD_GET); JsonNode data = objectMapper.readTree(getInputStreamFromURLConnection(conn)); results = parseForecasts(data.get("forecast")); } catch ( IOException e ) { log.warn("Error reading Weather Underground URL [{}]: {}", url, e.getMessage()); } return results; } @Override public Collection<DayDatum> getTenDayForecast(String identifier) { if ( identifier == null ) { return null; } final String url = urlForActionPath("forecast10day", identifier + ".json"); Collection<DayDatum> results = new ArrayList<DayDatum>(); try { URLConnection conn = getURLConnection(url, HTTP_METHOD_GET); JsonNode data = objectMapper.readTree(getInputStreamFromURLConnection(conn)); results = parseForecasts(data.get("forecast")); } catch ( IOException e ) { log.warn("Error reading Weather Underground URL [{}]: {}", url, e.getMessage()); } return results; } private Collection<DayDatum> parseForecasts(JsonNode node) { if ( node == null ) { return Collections.emptyList(); } JsonNode simpleForecast = node.get("simpleforecast"); if ( simpleForecast == null ) { return Collections.emptyList(); } JsonNode dayArrayNode = simpleForecast.get("forecastday"); if ( dayArrayNode == null || !dayArrayNode.isArray() ) { return Collections.emptyList(); } Collection<DayDatum> results = new ArrayList<DayDatum>(); final int dayCount = dayArrayNode.size(); for ( int i = 1; i < dayCount; i++ ) { GeneralDayDatum day = parseForecast(node, i); if ( day != null ) { day.addTag(DayDatum.TAG_FORECAST); results.add(day); } } return results; } private GeneralDayDatum parseDay(JsonNode node) { if ( node == null ) { return null; } GeneralDayDatum datum = parseForecast(node.get("forecast"), 0); if ( datum == null ) { return null; } JsonNode moonNode = node.get("moon_phase"); datum.setMoonrise(parseHourMinuteNode(moonNode.get("moonrise"))); datum.setMoonset(parseHourMinuteNode(moonNode.get("moonset"))); datum.setSunrise(parseHourMinuteNode(moonNode.get("sunrise"))); datum.setSunset(parseHourMinuteNode(moonNode.get("sunset"))); return datum; } private LocalTime parseHourMinuteNode(JsonNode node) { if ( node == null ) { return null; } Integer hour = parseIntegerAttribute(node, "hour"); Integer min = parseIntegerAttribute(node, "minute"); return (hour != null && min != null ? new LocalTime(hour.intValue(), min.intValue()) : null); } private GeneralDayDatum parseForecast(final JsonNode node, final int dayOffset) { if ( node == null ) { return null; } GeneralDayDatum datum = new GeneralDayDatum(); JsonNode forecastNode = node.get("simpleforecast"); if ( forecastNode == null ) { return null; } JsonNode dayArrayNode = forecastNode.get("forecastday"); if ( dayArrayNode == null || !dayArrayNode.isArray() ) { return null; } JsonNode dayNode = dayArrayNode.get(dayOffset); JsonNode dateNode = dayNode.get("date"); String tz = parseStringAttribute(dateNode, "tz_long"); Long epoch = parseLongAttribute(dateNode, "epoch"); if ( tz != null && epoch != null ) { LocalDate date = new LocalDate(epoch.longValue() * 1000, DateTimeZone.forID(tz)); datum.setCreated(date.toDate()); } datum.setRain(parseIntegerAttribute(dayNode.get("qpf_allday"), "mm")); datum.setSkyConditions(parseStringAttribute(dayNode, "conditions")); JsonNode snowNode = dayNode.get("snow_allday"); if ( snowNode != null ) { Integer snowCm = parseIntegerAttribute(snowNode, "cm"); if ( snowCm != null ) { // convert snow to mm datum.setSnow(snowCm.intValue() * 10); } } JsonNode tempNode = dayNode.get("high"); if ( tempNode != null ) { datum.setTemperatureMaximum(parseBigDecimalAttribute(tempNode, "celsius")); } tempNode = dayNode.get("low"); if ( tempNode != null ) { datum.setTemperatureMinimum(parseBigDecimalAttribute(tempNode, "celsius")); } JsonNode windNode = dayNode.get("avewind"); if ( windNode != null ) { datum.setWindDirection(parseIntegerAttribute(windNode, "degrees")); datum.setWindSpeed(parseWindSpeed(windNode, "kph")); } JsonNode txtNode = node.get("txt_forecast"); if ( txtNode != null ) { JsonNode txtDayArrayNode = txtNode.get("forecastday"); if ( txtDayArrayNode != null && txtDayArrayNode.isArray() ) { // txt day nodes come in pairs, one for day one for night; we only get day values JsonNode txtDayNode = txtDayArrayNode.get(dayOffset * 2); if ( txtDayNode != null ) { // TODO: support imperial description gathering via class property datum.setBriefOverview(parseStringAttribute(txtDayNode, "fcttext_metric")); } } } return datum; } private BigDecimal parseWindSpeed(JsonNode windNode, String key) { if ( windNode == null ) { return null; } BigDecimal wspeed = parseBigDecimalAttribute(windNode, key); if ( wspeed == null ) { return null; } // convert kph to mps return wspeed.multiply(new BigDecimal(10)).divide(new BigDecimal(36), 3, RoundingMode.HALF_UP); } private GeneralAtmosphericDatum parseHourlyForecast(JsonNode node) { if ( node == null ) { return null; } JsonNode objNode = node.get("FCTTIME"); if ( objNode == null ) { return null; } GeneralAtmosphericDatum datum = new GeneralAtmosphericDatum(); Long ts = parseLongAttribute(objNode, "epoch"); if ( ts != null ) { datum.setCreated(new Date(ts.longValue() * 1000)); } datum.addTag(AtmosphericDatum.TAG_FORECAST); objNode = node.get("mslp"); Integer pres = parseIntegerAttribute(objNode, "metric"); if ( pres != null ) { // convert to pascals datum.setAtmosphericPressure(pres.intValue() * 100); } objNode = node.get("dewpoint"); datum.setDewPoint(parseBigDecimalAttribute(objNode, "metric")); datum.setHumidity(parseIntegerAttribute(node, "humidity")); objNode = node.get("qpf"); datum.setRain(parseIntegerAttribute(objNode, "metric")); datum.setSkyConditions(parseStringAttribute(node, "condition")); objNode = node.get("snow"); datum.setSnow(parseIntegerAttribute(objNode, "metric")); objNode = node.get("temp"); datum.setTemperature(parseBigDecimalAttribute(objNode, "metric")); objNode = node.get("wdir"); datum.setWindDirection(parseIntegerAttribute(objNode, "degrees")); objNode = node.get("wspd"); datum.setWindSpeed(parseWindSpeed(objNode, "metric")); return datum; } private GeneralAtmosphericDatum parseConditions(JsonNode node) { if ( node == null ) { return null; } GeneralAtmosphericDatum datum = new GeneralAtmosphericDatum(); Long ts = parseLongAttribute(node, "observation_epoch"); if ( ts != null ) { datum.setCreated(new Date(ts.longValue() * 1000)); } Integer mb = parseIntegerAttribute(node, "pressure_mb"); if ( mb != null ) { // convert millibar to pascals datum.setAtmosphericPressure(mb.intValue() * 100); } BigDecimal dp = parseBigDecimalAttribute(node, "dewpoint_c"); if ( dp != null ) { datum.setDewPoint(dp); } String hum = parseStringAttribute(node, "relative_humidity"); if ( hum != null ) { if ( hum.endsWith("%") ) { hum = hum.substring(0, hum.length() - 1); } try { datum.setHumidity(Integer.valueOf(hum)); } catch ( NumberFormatException e ) { log.warn("Unable to parse 'relative_humidity' attribute [{}]", hum); } } datum.setRain(parseIntegerAttribute(node, "precip_1hr_metric")); datum.setSkyConditions(parseStringAttribute(node, "weather")); datum.setTemperature(parseBigDecimalAttribute(node, "temp_c")); BigDecimal vis = parseBigDecimalAttribute(node, "visibility_km"); if ( vis != null ) { // convert km to meters datum.setVisibility(vis.multiply(new BigDecimal(1000)).intValue()); } datum.setWindDirection(parseIntegerAttribute(node, "wind_degrees")); datum.setWindSpeed(parseWindSpeed(node, "wind_kph")); return datum; } private BasicWeatherUndergroundLocation parseLocation(JsonNode node) { if ( node == null ) { return null; } BasicWeatherUndergroundLocation loc = new BasicWeatherUndergroundLocation(); loc.setIdentifier(parseStringAttribute(node, "l")); loc.setCountry(parseStringAttribute(node, "country")); loc.setStateOrProvince(parseStringAttribute(node, "state")); loc.setLocality(parseStringAttribute(node, "city")); loc.setPostalCode(parseStringAttribute(node, "zip")); if ( "00000".equals(loc.getPostalCode()) ) { loc.setPostalCode(null); } loc.setTimeZoneId(parseStringAttribute(node, "tz_long")); loc.setLatitude(parseBigDecimalAttribute(node, "lat")); loc.setLongitude(parseBigDecimalAttribute(node, "lon")); // the autocomplete endpoint has the following loc.setName(parseStringAttribute(node, "name")); // from autocomplete endpoint if ( loc.getCountry() == null ) { loc.setCountry(parseStringAttribute(node, "c")); } if ( loc.getTimeZoneId() == null ) { loc.setTimeZoneId(parseStringAttribute(node, "tz")); } return loc; } /** * Set the Weather Underground API key to use. * * @param apiKey * the apiKey to set */ public void setApiKey(String apiKey) { this.apiKey = apiKey; } /** * Set the base URL to use. This defaults to {@link #DEFAULT_API_BASE_URL} * which should be sufficient for most cases. * * @param baseUrl * the baseUrl to set */ public void setBaseUrl(String baseUrl) { this.baseUrl = baseUrl; } /** * Set the base autocomplete URL to use. This defaults to * {@link #DEFAULT_AUTOCOMPLETE_BASE_URL} which should be sufficient for * most cases. * * @param baseAutocompleteUrl * the baseAutocompleteUrl to set */ public void setBaseAutocompleteUrl(String baseAutocompleteUrl) { this.baseAutocompleteUrl = baseAutocompleteUrl; } /** * Set the ObjectMapper to use for JSON parsing. * * @param objectMapper * the objectMapper to set */ public void setObjectMapper(ObjectMapper objectMapper) { this.objectMapper = objectMapper; } }