forked from home-assistant/core
/
bom.py
329 lines (274 loc) · 11.4 KB
/
bom.py
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
"""
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/sensor.bom/
"""
import datetime
import ftplib
import gzip
import io
import json
import logging
import os
import re
import zipfile
import requests
import voluptuous as vol
import homeassistant.helpers.config_validation as cv
from homeassistant.components.sensor import PLATFORM_SCHEMA
from homeassistant.const import (
CONF_MONITORED_CONDITIONS, TEMP_CELSIUS, CONF_NAME, ATTR_ATTRIBUTION,
CONF_LATITUDE, CONF_LONGITUDE)
from homeassistant.helpers.entity import Entity
from homeassistant.util import Throttle
_RESOURCE = 'http://www.bom.gov.au/fwo/{}/{}.{}.json'
_LOGGER = logging.getLogger(__name__)
ATTR_LAST_UPDATE = 'last_update'
ATTR_SENSOR_ID = 'sensor_id'
ATTR_STATION_ID = 'station_id'
ATTR_STATION_NAME = 'station_name'
ATTR_ZONE_ID = 'zone_id'
CONF_ATTRIBUTION = "Data provided by the Australian Bureau of Meteorology"
CONF_STATION = 'station'
CONF_ZONE_ID = 'zone_id'
CONF_WMO_ID = 'wmo_id'
MIN_TIME_BETWEEN_UPDATES = datetime.timedelta(seconds=60)
SENSOR_TYPES = {
'wmo': ['wmo', None],
'name': ['Station Name', None],
'history_product': ['Zone', None],
'local_date_time': ['Local Time', None],
'local_date_time_full': ['Local Time Full', None],
'aifstime_utc': ['UTC Time Full', None],
'lat': ['Lat', None],
'lon': ['Long', None],
'apparent_t': ['Feels Like C', TEMP_CELSIUS],
'cloud': ['Cloud', None],
'cloud_base_m': ['Cloud Base', None],
'cloud_oktas': ['Cloud Oktas', None],
'cloud_type_id': ['Cloud Type ID', None],
'cloud_type': ['Cloud Type', None],
'delta_t': ['Delta Temp C', TEMP_CELSIUS],
'gust_kmh': ['Wind Gust kmh', 'km/h'],
'gust_kt': ['Wind Gust kt', 'kt'],
'air_temp': ['Air Temp C', TEMP_CELSIUS],
'dewpt': ['Dew Point C', TEMP_CELSIUS],
'press': ['Pressure mb', 'mbar'],
'press_qnh': ['Pressure qnh', 'qnh'],
'press_msl': ['Pressure msl', 'msl'],
'press_tend': ['Pressure Tend', None],
'rain_trace': ['Rain Today', 'mm'],
'rel_hum': ['Relative Humidity', '%'],
'sea_state': ['Sea State', None],
'swell_dir_worded': ['Swell Direction', None],
'swell_height': ['Swell Height', 'm'],
'swell_period': ['Swell Period', None],
'vis_km': ['Visability km', 'km'],
'weather': ['Weather', None],
'wind_dir': ['Wind Direction', None],
'wind_spd_kmh': ['Wind Speed kmh', 'km/h'],
'wind_spd_kt': ['Wind Speed 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.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): cv.string,
vol.Optional(CONF_STATION): validate_station,
vol.Required(CONF_MONITORED_CONDITIONS, default=[]):
vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]),
})
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the BOM sensor."""
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
bom_data = BOMCurrentData(hass, station)
try:
bom_data.update()
except ValueError as err:
_LOGGER.error("Received error from BOM Current: %s", err)
return
add_entities([BOMCurrentSensor(bom_data, variable, config.get(CONF_NAME))
for variable in config[CONF_MONITORED_CONDITIONS]])
class BOMCurrentSensor(Entity):
"""Implementation of a BOM current sensor."""
def __init__(self, bom_data, condition, stationname):
"""Initialize the sensor."""
self.bom_data = bom_data
self._condition = condition
self.stationname = stationname
@property
def name(self):
"""Return the name of the sensor."""
if self.stationname is None:
return 'BOM {}'.format(SENSOR_TYPES[self._condition][0])
return 'BOM {} {}'.format(
self.stationname, SENSOR_TYPES[self._condition][0])
@property
def state(self):
"""Return the state of the sensor."""
return self.bom_data.get_reading(self._condition)
@property
def device_state_attributes(self):
"""Return the state attributes of the device."""
attr = {
ATTR_ATTRIBUTION: CONF_ATTRIBUTION,
ATTR_LAST_UPDATE: datetime.datetime.strptime(
str(self.bom_data.latest_data['local_date_time_full']),
'%Y%m%d%H%M%S'),
ATTR_SENSOR_ID: self._condition,
ATTR_STATION_ID: self.bom_data.latest_data['wmo'],
ATTR_STATION_NAME: self.bom_data.latest_data['name'],
ATTR_ZONE_ID: self.bom_data.latest_data['history_product'],
}
return attr
@property
def unit_of_measurement(self):
"""Return the units of measurement."""
return SENSOR_TYPES[self._condition][1]
def update(self):
"""Update current conditions."""
self.bom_data.update()
class BOMCurrentData:
"""Get data from BOM."""
def __init__(self, hass, station_id):
"""Initialize the data object."""
self._hass = hass
self._zone_id, self._wmo_id = station_id.split('.')
self._data = None
self._last_updated = None
def _build_url(self):
"""Build the URL for the requests."""
url = _RESOURCE.format(self._zone_id, self._zone_id, self._wmo_id)
_LOGGER.debug("BOM URL: %s", url)
return url
@property
def latest_data(self):
"""Return the latest data object."""
if self._data:
return self._data[0]
return None
def get_reading(self, condition):
"""Return the value for the given condition.
BOM weather publishes condition readings for weather (and a few other
conditions) at intervals throughout the day. To avoid a `-` value in
the frontend for these conditions, we traverse the historical data
for the latest value that is not `-`.
Iterators are used in this method to avoid iterating needlessly
through the entire BOM provided dataset.
"""
condition_readings = (entry[condition] for entry in self._data)
return next((x for x in condition_readings if x != '-'), None)
def should_update(self):
if self._last_updated is None:
# Never updated before, therefore an update should occur.
return True
now = datetime.datetime.now()
update_due_at = self._last_updated + datetime.timedelta(minutes=35)
return now > update_due_at
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Get the latest data from BOM.
BOM provides updated data every 30 minutes. We manually define
refreshing logic here rather than a throttle to keep updates
in lock-step with BOM.
If 35 minutes has passed since the last BOM data update, then
an update should be done.
"""
if not self.should_update():
_LOGGER.debug(
"BOM was updated %s minutes ago, skipping update as"
" < 35 minutes, Now: %s, LastUpdate: %s",
(datetime.datetime.now() - self._last_updated),
datetime.datetime.now(), self._last_updated)
return
try:
result = requests.get(self._build_url(), timeout=10).json()
self._data = result['observations']['data']
# set lastupdate using self._data[0] as the first element in the
# array is the latest date in the json
self._last_updated = datetime.datetime.strptime(
str(self._data[0]['local_date_time_full']), '%Y%m%d%H%M%S')
return
except ValueError as err:
_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):
"""Create a psudeo-distance from latitude/longitude."""
station_lat, station_lon = stations[wmo_id]
return (lat - station_lat) ** 2 + (lon - station_lon) ** 2
return min(stations, key=comparable_dist)