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/tts/picotts.py
homeassistant/components/upnp.py
homeassistant/components/weather/bom.py
homeassistant/components/weather/openweathermap.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/
"""
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'<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
def pressure(self):
"""Return the wind speed."""
"""Return the pressure."""
return self._pressure
@property