Bom weather platform (#5153)

* Fix typo

* Auto-config for `sensor.bom`

Deprecate (but still support) the old two-part station ID, and move to a
single `station` identifier.  Any combination of these, including none,
is valid; most results in downloading and caching the station map to
work out any missing info.

* Add `weather.bom` platform

Very similar to `sensor.bom`, but supporting the lovely new `weather`
component interface.  Easier to configure, and does not support the
deprecated config options.

* Review improvements to BOM weather

Largely around better input validation.
This commit is contained in:
Zac Hatfield Dodds 2017-01-15 22:12:50 +11:00 committed by Fabian Affolter
parent 9fff634b9d
commit e00e6f9db6
4 changed files with 221 additions and 18 deletions

View file

@ -364,6 +364,7 @@ omit =
homeassistant/components/thingspeak.py homeassistant/components/thingspeak.py
homeassistant/components/tts/picotts.py homeassistant/components/tts/picotts.py
homeassistant/components/upnp.py homeassistant/components/upnp.py
homeassistant/components/weather/bom.py
homeassistant/components/weather/openweathermap.py homeassistant/components/weather/openweathermap.py
homeassistant/components/zeroconf.py homeassistant/components/zeroconf.py

View file

@ -5,15 +5,22 @@ For more details about this platform, please refer to the documentation at
https://home-assistant.io/components/sensor.bom/ https://home-assistant.io/components/sensor.bom/
""" """
import datetime import datetime
import ftplib
import gzip
import io
import json
import logging import logging
import requests import os
import re
import zipfile
import requests
import voluptuous as vol import voluptuous as vol
from homeassistant.components.sensor import PLATFORM_SCHEMA from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import ( from homeassistant.const import (
CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, STATE_UNKNOWN, CONF_NAME, 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.helpers.entity import Entity
from homeassistant.util import Throttle from homeassistant.util import Throttle
import homeassistant.helpers.config_validation as cv import homeassistant.helpers.config_validation as cv
@ -22,6 +29,7 @@ _RESOURCE = 'http://www.bom.gov.au/fwo/{}/{}.{}.json'
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONF_ATTRIBUTION = "Data provided by the Australian Bureau of Meteorology" CONF_ATTRIBUTION = "Data provided by the Australian Bureau of Meteorology"
CONF_STATION = 'station'
CONF_ZONE_ID = 'zone_id' CONF_ZONE_ID = 'zone_id'
CONF_WMO_ID = 'wmo_id' CONF_WMO_ID = 'wmo_id'
@ -66,10 +74,22 @@ SENSOR_TYPES = {
'wind_spd_kt': ['Wind Direction kt', 'kt'] '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({ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({
vol.Required(CONF_ZONE_ID): cv.string, vol.Inclusive(CONF_ZONE_ID, 'Deprecated partial station ID'): cv.string,
vol.Required(CONF_WMO_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_NAME, default=None): cv.string,
vol.Optional(CONF_STATION): validate_station,
vol.Required(CONF_MONITORED_CONDITIONS, default=[]): vol.Required(CONF_MONITORED_CONDITIONS, default=[]):
vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), 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): def setup_platform(hass, config, add_devices, discovery_info=None):
"""Set up the BOM sensor.""" """Set up the BOM sensor."""
rest = BOMCurrentData( station = config.get(CONF_STATION)
hass, config.get(CONF_ZONE_ID), config.get(CONF_WMO_ID)) zone_id, wmo_id = config.get(CONF_ZONE_ID), config.get(CONF_WMO_ID)
if station is not None:
sensors = [] if zone_id and wmo_id:
for variable in config[CONF_MONITORED_CONDITIONS]: _LOGGER.warning(
sensors.append(BOMCurrentSensor( 'Using config "%s", not "%s" and "%s" for BOM sensor',
rest, variable, config.get(CONF_NAME))) 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: try:
rest.update() rest.update()
except ValueError as err: except ValueError as err:
_LOGGER.error("Received error from BOM_Current: %s", err) _LOGGER.error("Received error from BOM_Current: %s", err)
return False return False
add_devices([BOMCurrentSensor(rest, variable, config.get(CONF_NAME))
add_devices(sensors) for variable in config[CONF_MONITORED_CONDITIONS]])
return True return True
@ -148,11 +177,10 @@ class BOMCurrentSensor(Entity):
class BOMCurrentData(object): class BOMCurrentData(object):
"""Get data from BOM.""" """Get data from BOM."""
def __init__(self, hass, zone_id, wmo_id): def __init__(self, hass, station_id):
"""Initialize the data object.""" """Initialize the data object."""
self._hass = hass self._hass = hass
self._zone_id = zone_id self._zone_id, self._wmo_id = station_id.split('.')
self._wmo_id = wmo_id
self.data = None self.data = None
self._lastupdate = LAST_UPDATE self._lastupdate = LAST_UPDATE
@ -182,3 +210,70 @@ class BOMCurrentData(object):
_LOGGER.error("Check BOM %s", err.args) _LOGGER.error("Check BOM %s", err.args)
self.data = None self.data = None
raise 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'<a href="/products/(?P<zone>ID[A-Z]\d\d\d\d\d)/'
r'(?P=zone)\.(?P<wmo>\d\d\d\d\d).shtml">')
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)

View file

@ -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"

View file

@ -79,7 +79,7 @@ class DemoWeather(WeatherEntity):
@property @property
def pressure(self): def pressure(self):
"""Return the wind speed.""" """Return the pressure."""
return self._pressure return self._pressure
@property @property