'''Library to handle communications with the Met Éireann forecast and weather warning APIs.''' import asyncio import datetime import logging from xml.parsers.expat import ExpatError import aiohttp import async_timeout import pytz import xmltodict API_URL = 'http://openaccess.pf.api.met.ie/metno-wdb2ts/locationforecast' WARNING_API_URL = 'https://www.met.ie/Open_Data/json/warning_' # Map each region code to a region name REGION_MAP = { # Counties 'EI01': 'Carlow', 'EI02': 'Cavan', 'EI03': 'Clare', 'EI04': 'Cork', 'EI06': 'Donegal', 'EI07': 'Dublin', 'EI10': 'Galway', 'EI11': 'Kerry', 'EI12': 'Kildare', 'EI13': 'Kilkenny', 'EI15': 'Laois', 'EI14': 'Leitrim', 'EI16': 'Limerick', 'EI18': 'Longford', 'EI19': 'Louth', 'EI20': 'Mayo', 'EI21': 'Meath', 'EI22': 'Monaghan', 'EI23': 'Offaly', 'EI24': 'Roscommon', 'EI25': 'Sligo', 'EI26': 'Tipperary', 'EI27': 'Waterford', 'EI29': 'Westmeath', 'EI30': 'Wexford', 'EI31': 'Wicklow', # Marine 'EI805': 'Malin-Fair', 'EI806': 'Fair-Belfast', 'EI807': 'Belfast-Strang', 'EI808': 'Strang-Carl', 'EI809': 'Carling-Howth', 'EI810': 'Howth-Wicklow', 'EI811': 'Wicklow-Carns', 'EI812': 'Carns-Hook', 'EI813': 'Hook-Dungarvan', 'EI814': 'Dungarvan-Roches', 'EI815': 'Roches-Mizen', 'EI816': 'Mizen-Valentia', 'EI817': 'Valentia-Loop', 'EI818': 'Loop-Slayne', 'EI819': 'Slayne-Ennis', 'EI820': 'Erris-Rossan', 'EI821': 'Rossan-BloodyF', 'EI822': 'BloodyF-Malin', 'EI823': 'IrishSea-South', 'EI824': 'IrishSea-IOM-S', 'EI825': 'IrishSea-IOM-N' } _LOGGER = logging.getLogger(__name__) class WarningData: '''Representation of Met Éireann warning data.''' def __init__(self, websession=None, api_url=WARNING_API_URL, region='Ireland', convert_to_utc=True, ignore_blight=True): '''Initialize the warning object.''' # pylint: disable=too-many-arguments # Set the various option variables self._ignore_blight = ignore_blight self._convert_to_utc = convert_to_utc # Convert region name to region code (if applicable) if not region.upper().startswith('EI') and region.upper() != 'IRELAND': keys = [k for k, v in REGION_MAP.items() if v == region] self._region = keys[0].upper() else: self._region = region.upper() # Set the API URL to include the required region self._api_url = f'{api_url}{self._region}.json' # Create a new session if one isn't passed in if websession is None: async def _create_session(): self.created_session = True return aiohttp.ClientSession() loop = asyncio.get_event_loop() self._websession = loop.run_until_complete(_create_session()) else: self._websession = websession self.created_session = False self.data = None async def fetching_data(self, *_): '''Get the latest data from the warning API''' try: with async_timeout.timeout(10): res = await self._websession.get(self._api_url) # Log any 400+ HTTP error codes if res.status >= 400: _LOGGER.error('%s returned %s', self._api_url, res.status) return False json = await res.json() except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error('%s returned %s', self._api_url, err) return False try: self.data = { 'count': len(json), 'warnings': json } except (ExpatError, IndexError) as err: _LOGGER.error('%s returned %s', res.url, err) return False return True def get_warnings(self): '''Get the latest warnings from Met Éireann.''' if self.data is None: return {'count': 0, 'warnings': []} # Update the timestamps to datetime objects (and to UTC if required) for entry in self.data['warnings']: entry['issued'] = format_warning_date(entry['issued'], self._convert_to_utc) entry['updated'] = format_warning_date(entry['updated'], self._convert_to_utc) entry['onset'] = format_warning_date(entry['onset'], self._convert_to_utc) entry['expiry'] = format_warning_date(entry['expiry'], self._convert_to_utc) # Remove blight warnings from the list if required if self._ignore_blight: new_warnings_list = [] for entry in self.data['warnings']: if entry['type'].lower() != 'blight': new_warnings_list.append(entry) return {'count': len(new_warnings_list), 'warnings': new_warnings_list} return self.data async def close_session(self): '''Close a session if the user did not pass one in.''' if self.created_session: await self._websession.close() _LOGGER.debug('Closed session (Warnings)') else: _LOGGER.warning('Cannot close an external session') class WeatherData: '''Representation of Met Éireann weather data.''' def __init__(self, websession=None, api_url=API_URL, latitude=54.7210798611, longitude=-8.7237392806, altitude=0): '''Initialize the weather object.''' # pylint: disable=too-many-arguments # Get the current UTC time now = datetime.datetime.utcnow() # Store the forecast parameters self._api_url = f'{api_url}?lat={latitude};long={longitude};alt={altitude};from={now.date()}T{now.hour}:00' # Create a new session if one isn't passed in if websession is None: async def _create_session(): self.created_session = True return aiohttp.ClientSession() loop = asyncio.get_event_loop() self._websession = loop.run_until_complete(_create_session()) else: self._websession = websession self.created_session = False self.data = None async def fetching_data(self, *_): '''Get the latest data from the API''' try: with async_timeout.timeout(10): resp = await self._websession.get(self._api_url) # Log any 400+ HTTP error codes if resp.status >= 400: _LOGGER.error('%s returned %s', self._api_url, resp.status) return False text = await resp.text() except (asyncio.TimeoutError, aiohttp.ClientError) as err: _LOGGER.error('%s returned %s', self._api_url, err) return False try: self.data = xmltodict.parse(text)['weatherdata'] except (ExpatError, IndexError) as err: _LOGGER.error('%s returned %s', resp.url, err) return False return True def get_current_weather(self): '''Get the current weather data from Met Éireann.''' return self.get_weather(datetime.datetime.now(pytz.utc).replace(minute=0, second=0, microsecond=0), hourly=True) def get_forecast(self, time_zone, hourly=False): '''Get the forecast weather data from Met Éireann.''' if self.data is None: return [] if hourly: now = datetime.datetime.now(time_zone).replace( minute=0, second=0, microsecond=0 ) times = [now + datetime.timedelta(hours=k) for k in range(1, 25)] else: now = datetime.datetime.now(time_zone).replace( hour=12, minute=0, second=0, microsecond=0 ) times = [now + datetime.timedelta(days=k) for k in range(1, 6)] return [self.get_weather(_time, hourly=hourly) for _time in times] def get_weather(self, time, max_hour=6, hourly=False): '''Get the current weather data from Met Éireann.''' # pylint: disable=too-many-locals if self.data is None: return {} day = time.date() daily_temperatures = [] daily_precipitation = [] daily_windspeed = [] daily_windgust = [] ordered_entries = [] for time_entry in self.data['product']['time']: valid_from = parse_datetime(time_entry['@from']) valid_to = parse_datetime(time_entry['@to']) if time > valid_to: # Has already passed. Never select this. continue # Collect all daily values to calculate min/max/sum if valid_from.date() == day or valid_to.date() == day: if 'temperature' in time_entry['location']: daily_temperatures.append( get_value(time_entry['location']['temperature'], '@value') ) if 'precipitation' in time_entry['location']: daily_precipitation.append( get_value(time_entry['location']['precipitation'], '@value') ) if 'windSpeed' in time_entry['location']: daily_windspeed.append( get_value(time_entry['location']['windSpeed'], '@mps') ) if 'windGust' in time_entry['location']: daily_windgust.append( get_value(time_entry['location']['windGust'], '@mps') ) average_dist = abs((valid_to - time).total_seconds()) + abs( (valid_from - time).total_seconds() ) if average_dist > max_hour * 3600: continue ordered_entries.append((average_dist, time_entry)) if not ordered_entries: return {} ordered_entries.sort(key=lambda item: item[0]) res = dict() res['datetime'] = time res['condition'] = get_data('symbol', ordered_entries) res['pressure'] = get_data('pressure', ordered_entries) res['humidity'] = get_data('humidity', ordered_entries) res['wind_bearing'] = get_data('windDirection', ordered_entries) if hourly: res['temperature'] = get_data('temperature', ordered_entries) res['precipitation'] = get_data('precipitation', ordered_entries) res['wind_speed'] = get_data('windSpeed', ordered_entries) res['wind_gust'] = get_data('windGust', ordered_entries) res['cloudiness'] = get_data('cloudiness', ordered_entries) else: res['temperature'] = ( None if daily_temperatures == [] else max(daily_temperatures) ) res['templow'] = ( None if daily_temperatures == [] else min(daily_temperatures) ) res['precipitation'] = ( None if daily_precipitation == [] else round(sum(daily_precipitation), 1) ) res['wind_speed'] = ( None if daily_windspeed == [] else max(daily_windspeed) ) res['wind_gust'] = ( None if daily_windgust == [] else max(daily_windgust) ) return res async def close_session(self): '''Close a session if the user did not pass one in''' if self.created_session: await self._websession.close() _LOGGER.debug('Closed session (Forecast)') else: _LOGGER.warning('Cannot close an external session') def get_value(data, value): '''Retrieve weather value.''' try: if value == '@mps': return round(float(data[value]) * 3.6, 1) return round(float(data[value]), 1) except (ValueError, IndexError, KeyError): return None def get_data(param, data): '''Retrieve weather parameter.''' try: for (_, selected_time_entry) in data: loc_data = selected_time_entry['location'] if param not in loc_data: continue if param == 'symbol': new_state = loc_data[param]['@id'] elif param in ( 'temperature', 'pressure', 'humidity', 'dewpointTemperature', 'precipitation', ): new_state = get_value(loc_data[param], '@value') elif param in ('windSpeed', 'windGust'): new_state = get_value(loc_data[param], '@mps') elif param == 'windDirection': new_state = get_value(loc_data[param], '@deg') elif param in ( 'fog', 'cloudiness', 'lowClouds', 'mediumClouds', 'highClouds', ): new_state = get_value(loc_data[param], '@percent') return new_state except (ValueError, IndexError, KeyError): return None def parse_datetime(dt_str): '''Parse datetime for forecast data.''' date_format = '%Y-%m-%dT%H:%M:%S %z' dt_str = dt_str.replace('Z', ' +0000') return datetime.datetime.strptime(dt_str, date_format) def format_warning_date(date_str, convert_to_utc=False): '''Convert a timestamp string to datetime and convert to UTC if required.''' date_format = '%Y-%m-%dT%H:%M:%S%z' new_timestamp = datetime.datetime.strptime(date_str, date_format) # Convert the datetime object to UTC if required if convert_to_utc: return new_timestamp.astimezone(tz=datetime.timezone.utc) # Return just the datetime object if UTC isn't required return new_timestamp