diff --git a/.coveragerc b/.coveragerc index 3862b3015e4..506e51a63d8 100644 --- a/.coveragerc +++ b/.coveragerc @@ -364,6 +364,7 @@ omit = homeassistant/components/thingspeak.py homeassistant/components/tts/picotts.py homeassistant/components/upnp.py + homeassistant/components/weather/bom.py homeassistant/components/weather/openweathermap.py homeassistant/components/zeroconf.py diff --git a/homeassistant/components/sensor/bom.py b/homeassistant/components/sensor/bom.py index 3b6beab0510..a83ca49c619 100644 --- a/homeassistant/components/sensor/bom.py +++ b/homeassistant/components/sensor/bom.py @@ -5,15 +5,22 @@ For more details about this platform, please refer to the documentation at https://home-assistant.io/components/sensor.bom/ """ import datetime +import ftplib +import gzip +import io +import json import logging -import requests +import os +import re +import zipfile +import requests import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.const import ( CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, STATE_UNKNOWN, CONF_NAME, - ATTR_ATTRIBUTION) + ATTR_ATTRIBUTION, CONF_LATITUDE, CONF_LONGITUDE) from homeassistant.helpers.entity import Entity from homeassistant.util import Throttle import homeassistant.helpers.config_validation as cv @@ -22,6 +29,7 @@ _RESOURCE = 'http://www.bom.gov.au/fwo/{}/{}.{}.json' _LOGGER = logging.getLogger(__name__) CONF_ATTRIBUTION = "Data provided by the Australian Bureau of Meteorology" +CONF_STATION = 'station' CONF_ZONE_ID = 'zone_id' CONF_WMO_ID = 'wmo_id' @@ -66,10 +74,22 @@ SENSOR_TYPES = { 'wind_spd_kt': ['Wind Direction kt', 'kt'] } + +def validate_station(station): + """Check that the station ID is well-formed.""" + if station is None: + return + station = station.replace('.shtml', '') + if not re.fullmatch(r'ID[A-Z]\d\d\d\d\d\.\d\d\d\d\d', station): + raise vol.error.Invalid('Malformed station ID') + return station + + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_ZONE_ID): cv.string, - vol.Required(CONF_WMO_ID): cv.string, + vol.Inclusive(CONF_ZONE_ID, 'Deprecated partial station ID'): cv.string, + vol.Inclusive(CONF_WMO_ID, 'Deprecated partial station ID'): cv.string, vol.Optional(CONF_NAME, default=None): cv.string, + vol.Optional(CONF_STATION): validate_station, vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), }) @@ -77,22 +97,31 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ def setup_platform(hass, config, add_devices, discovery_info=None): """Set up the BOM sensor.""" - rest = BOMCurrentData( - hass, config.get(CONF_ZONE_ID), config.get(CONF_WMO_ID)) - - sensors = [] - for variable in config[CONF_MONITORED_CONDITIONS]: - sensors.append(BOMCurrentSensor( - rest, variable, config.get(CONF_NAME))) + station = config.get(CONF_STATION) + zone_id, wmo_id = config.get(CONF_ZONE_ID), config.get(CONF_WMO_ID) + if station is not None: + if zone_id and wmo_id: + _LOGGER.warning( + 'Using config "%s", not "%s" and "%s" for BOM sensor', + CONF_STATION, CONF_ZONE_ID, CONF_WMO_ID) + elif zone_id and wmo_id: + station = '{}.{}'.format(zone_id, wmo_id) + else: + station = closest_station(config.get(CONF_LATITUDE), + config.get(CONF_LONGITUDE), + hass.config.config_dir) + if station is None: + _LOGGER.error("Could not get BOM weather station from lat/lon") + return False + rest = BOMCurrentData(hass, station) try: rest.update() except ValueError as err: _LOGGER.error("Received error from BOM_Current: %s", err) return False - - add_devices(sensors) - + add_devices([BOMCurrentSensor(rest, variable, config.get(CONF_NAME)) + for variable in config[CONF_MONITORED_CONDITIONS]]) return True @@ -148,11 +177,10 @@ class BOMCurrentSensor(Entity): class BOMCurrentData(object): """Get data from BOM.""" - def __init__(self, hass, zone_id, wmo_id): + def __init__(self, hass, station_id): """Initialize the data object.""" self._hass = hass - self._zone_id = zone_id - self._wmo_id = wmo_id + self._zone_id, self._wmo_id = station_id.split('.') self.data = None self._lastupdate = LAST_UPDATE @@ -182,3 +210,70 @@ class BOMCurrentData(object): _LOGGER.error("Check BOM %s", err.args) self.data = None raise + + +def _get_bom_stations(): + """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config. + + This function does several MB of internet requests, so please use the + caching version to minimise latency and hit-count. + """ + latlon = {} + with io.BytesIO() as file_obj: + with ftplib.FTP('ftp.bom.gov.au') as ftp: + ftp.login() + ftp.cwd('anon2/home/ncc/metadata/sitelists') + ftp.retrbinary('RETR stations.zip', file_obj.write) + file_obj.seek(0) + with zipfile.ZipFile(file_obj) as zipped: + with zipped.open('stations.txt') as station_txt: + for _ in range(4): + station_txt.readline() # skip header + while True: + line = station_txt.readline().decode().strip() + if len(line) < 120: + break # end while loop, ignoring any footer text + wmo, lat, lon = (line[a:b].strip() for a, b in + [(128, 134), (70, 78), (79, 88)]) + if wmo != '..': + latlon[wmo] = (float(lat), float(lon)) + zones = {} + pattern = (r'') + for state in ('nsw', 'vic', 'qld', 'wa', 'tas', 'nt'): + url = 'http://www.bom.gov.au/{0}/observations/{0}all.shtml'.format( + state) + for zone_id, wmo_id in re.findall(pattern, requests.get(url).text): + zones[wmo_id] = zone_id + return {'{}.{}'.format(zones[k], k): latlon[k] + for k in set(latlon) & set(zones)} + + +def bom_stations(cache_dir): + """Return {CONF_STATION: (lat, lon)} for all stations, for auto-config. + + Results from internet requests are cached as compressed json, making + subsequent calls very much faster. + """ + cache_file = os.path.join(cache_dir, '.bom-stations.json.gz') + if not os.path.isfile(cache_file): + stations = _get_bom_stations() + with gzip.open(cache_file, 'wt') as cache: + json.dump(stations, cache, sort_keys=True) + return stations + with gzip.open(cache_file, 'rt') as cache: + return {k: tuple(v) for k, v in json.load(cache).items()} + + +def closest_station(lat, lon, cache_dir): + """Return the ZONE_ID.WMO_ID of the closest station to our lat/lon.""" + if lat is None or lon is None or not os.path.isdir(cache_dir): + return + stations = bom_stations(cache_dir) + + def comparable_dist(wmo_id): + """A fast key function for psudeo-distance from lat/lon.""" + station_lat, station_lon = stations[wmo_id] + return (lat - station_lat) ** 2 + (lon - station_lon) ** 2 + + return min(stations, key=comparable_dist) diff --git a/homeassistant/components/weather/bom.py b/homeassistant/components/weather/bom.py new file mode 100644 index 00000000000..236aeb2fa2e --- /dev/null +++ b/homeassistant/components/weather/bom.py @@ -0,0 +1,107 @@ +""" +Support for Australian BOM (Bureau of Meteorology) weather service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/weather.bom/ +""" +import logging + +import voluptuous as vol + +from homeassistant.components.weather import WeatherEntity, PLATFORM_SCHEMA +from homeassistant.const import \ + CONF_NAME, TEMP_CELSIUS, CONF_LATITUDE, CONF_LONGITUDE +from homeassistant.helpers import config_validation as cv +# Reuse data and API logic from the sensor implementation +from homeassistant.components.sensor.bom import \ + BOMCurrentData, closest_station, CONF_STATION, validate_station + +_LOGGER = logging.getLogger(__name__) + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_STATION): validate_station, +}) + + +def setup_platform(hass, config, add_devices, discovery_info=None): + """Set up the BOM weather platform.""" + station = config.get(CONF_STATION) or closest_station( + config.get(CONF_LATITUDE), + config.get(CONF_LONGITUDE), + hass.config.config_dir) + if station is None: + _LOGGER.error("Could not get BOM weather station from lat/lon") + return False + bom_data = BOMCurrentData(hass, station) + try: + bom_data.update() + except ValueError as err: + _LOGGER.error("Received error from BOM_Current: %s", err) + return False + add_devices([BOMWeather(bom_data, config.get(CONF_NAME))], True) + + +class BOMWeather(WeatherEntity): + """Representation of a weather condition.""" + + def __init__(self, bom_data, stationname=None): + """Initialise the platform with a data instance and station name.""" + self.bom_data = bom_data + self.stationname = stationname or self.bom_data.data.get('name') + + def update(self): + """Update current conditions.""" + self.bom_data.update() + + @property + def name(self): + """Return the name of the sensor.""" + return 'BOM {}'.format(self.stationname or '(unknown station)') + + @property + def condition(self): + """Return the current condition.""" + return self.bom_data.data.get('weather') + + # Now implement the WeatherEntity interface + + @property + def temperature(self): + """Return the platform temperature.""" + return self.bom_data.data.get('air_temp') + + @property + def temperature_unit(self): + """Return the unit of measurement.""" + return TEMP_CELSIUS + + @property + def pressure(self): + """Return the mean sea-level pressure.""" + return self.bom_data.data.get('press_msl') + + @property + def humidity(self): + """Return the relative humidity.""" + return self.bom_data.data.get('rel_hum') + + @property + def wind_speed(self): + """Return the wind speed.""" + return self.bom_data.data.get('wind_spd_kmh') + + @property + def wind_bearing(self): + """Return the wind bearing.""" + directions = ['N', 'NNE', 'NE', 'ENE', + 'E', 'ESE', 'SE', 'SSE', + 'S', 'SSW', 'SW', 'WSW', + 'W', 'WNW', 'NW', 'NNW'] + wind = {name: idx * 360 / 16 for idx, name in enumerate(directions)} + return wind.get(self.bom_data.data.get('wind_dir')) + + @property + def attribution(self): + """Return the attribution.""" + return "Data provided by the Australian Bureau of Meteorology" diff --git a/homeassistant/components/weather/demo.py b/homeassistant/components/weather/demo.py index f7617bd0075..b919722f2a4 100644 --- a/homeassistant/components/weather/demo.py +++ b/homeassistant/components/weather/demo.py @@ -79,7 +79,7 @@ class DemoWeather(WeatherEntity): @property def pressure(self): - """Return the wind speed.""" + """Return the pressure.""" return self._pressure @property