From 2c7060896b8bf61ab3a6c3c1c0d7f4022aff92fd Mon Sep 17 00:00:00 2001 From: Aaron Bach <bachya1208@gmail.com> Date: Mon, 28 Jan 2019 16:35:39 -0700 Subject: [PATCH] Make Ambient PWS async and cloud-push (#20332) * Moving existing sensor file * Initial functionality in place * Added test for config flow * Updated coverage and CODEOWNERS * Linting * Linting * Member comments * Hound * Moving socket disconnect on HASS stop * Member comments * Removed unnecessary dispatcher call * Config entry fix * Added support in config flow for good accounts with no devices * Hound * Updated comment * Member comments * Stale docstrings * Stale docstring --- .coveragerc | 4 +- CODEOWNERS | 1 + .../ambient_station/.translations/en.json | 19 ++ .../components/ambient_station/__init__.py | 212 ++++++++++++++++++ .../components/ambient_station/config_flow.py | 72 ++++++ .../components/ambient_station/const.py | 13 ++ .../components/ambient_station/sensor.py | 115 ++++++++++ .../components/ambient_station/strings.json | 19 ++ .../components/sensor/ambient_station.py | 212 ------------------ homeassistant/config_entries.py | 1 + requirements_all.txt | 6 +- requirements_test_all.txt | 3 + script/gen_requirements_all.py | 1 + tests/components/ambient_station/__init__.py | 1 + .../ambient_station/test_config_flow.py | 130 +++++++++++ tests/fixtures/ambient_devices.json | 15 ++ 16 files changed, 608 insertions(+), 216 deletions(-) create mode 100644 homeassistant/components/ambient_station/.translations/en.json create mode 100644 homeassistant/components/ambient_station/__init__.py create mode 100644 homeassistant/components/ambient_station/config_flow.py create mode 100644 homeassistant/components/ambient_station/const.py create mode 100644 homeassistant/components/ambient_station/sensor.py create mode 100644 homeassistant/components/ambient_station/strings.json delete mode 100644 homeassistant/components/sensor/ambient_station.py create mode 100644 tests/components/ambient_station/__init__.py create mode 100644 tests/components/ambient_station/test_config_flow.py create mode 100644 tests/fixtures/ambient_devices.json diff --git a/.coveragerc b/.coveragerc index 2d4fb3f81a7..32bcda136ca 100644 --- a/.coveragerc +++ b/.coveragerc @@ -19,6 +19,9 @@ omit = homeassistant/components/alarmdecoder.py homeassistant/components/*/alarmdecoder.py + homeassistant/components/ambient_station/__init__.py + homeassistant/components/ambient_station/sensor.py + homeassistant/components/amcrest.py homeassistant/components/*/amcrest.py @@ -732,7 +735,6 @@ omit = homeassistant/components/sensor/aftership.py homeassistant/components/sensor/airvisual.py homeassistant/components/sensor/alpha_vantage.py - homeassistant/components/sensor/ambient_station.py homeassistant/components/sensor/arest.py homeassistant/components/sensor/arwn.py homeassistant/components/sensor/bbox.py diff --git a/CODEOWNERS b/CODEOWNERS index 4b4931ecc3a..2a2391186f4 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -153,6 +153,7 @@ homeassistant/components/weather/openweathermap.py @fabaff homeassistant/components/xiaomi_aqara.py @danielhiversen @syssi # A +homeassistant/components/ambient_station/* @bachya homeassistant/components/arduino.py @fabaff homeassistant/components/*/arduino.py @fabaff homeassistant/components/*/arest.py @fabaff diff --git a/homeassistant/components/ambient_station/.translations/en.json b/homeassistant/components/ambient_station/.translations/en.json new file mode 100644 index 00000000000..5bd643da55c --- /dev/null +++ b/homeassistant/components/ambient_station/.translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "error": { + "identifier_exists": "Application Key and/or API Key already registered", + "invalid_key": "Invalid API Key and/or Application Key", + "no_devices": "No devices found in account" + }, + "step": { + "user": { + "data": { + "api_key": "API Key", + "app_key": "Application Key" + }, + "title": "Fill in your information" + } + }, + "title": "Ambient PWS" + } +} \ No newline at end of file diff --git a/homeassistant/components/ambient_station/__init__.py b/homeassistant/components/ambient_station/__init__.py new file mode 100644 index 00000000000..788927a2700 --- /dev/null +++ b/homeassistant/components/ambient_station/__init__.py @@ -0,0 +1,212 @@ +""" +Support for Ambient Weather Station Service. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/ambient_station/ +""" +import logging + +import voluptuous as vol + +from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.const import ( + ATTR_NAME, ATTR_LOCATION, CONF_API_KEY, CONF_MONITORED_CONDITIONS, + CONF_UNIT_SYSTEM, EVENT_HOMEASSISTANT_STOP) +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import aiohttp_client, config_validation as cv +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.event import async_call_later + +from .config_flow import configured_instances +from .const import ( + ATTR_LAST_DATA, CONF_APP_KEY, DATA_CLIENT, DOMAIN, TOPIC_UPDATE, UNITS_SI, + UNITS_US) + +REQUIREMENTS = ['aioambient==0.1.0'] +_LOGGER = logging.getLogger(__name__) + +DEFAULT_SOCKET_MIN_RETRY = 15 + +SENSOR_TYPES = { + '24hourrainin': ['24 Hr Rain', 'in'], + 'baromabsin': ['Abs Pressure', 'inHg'], + 'baromrelin': ['Rel Pressure', 'inHg'], + 'battout': ['Battery', ''], + 'co2': ['co2', 'ppm'], + 'dailyrainin': ['Daily Rain', 'in'], + 'dewPoint': ['Dew Point', ['°F', '°C']], + 'eventrainin': ['Event Rain', 'in'], + 'feelsLike': ['Feels Like', ['°F', '°C']], + 'hourlyrainin': ['Hourly Rain Rate', 'in/hr'], + 'humidity': ['Humidity', '%'], + 'humidityin': ['Humidity In', '%'], + 'lastRain': ['Last Rain', ''], + 'maxdailygust': ['Max Gust', 'mph'], + 'monthlyrainin': ['Monthly Rain', 'in'], + 'solarradiation': ['Solar Rad', 'W/m^2'], + 'tempf': ['Temp', ['°F', '°C']], + 'tempinf': ['Inside Temp', ['°F', '°C']], + 'totalrainin': ['Lifetime Rain', 'in'], + 'uv': ['uv', 'Index'], + 'weeklyrainin': ['Weekly Rain', 'in'], + 'winddir': ['Wind Dir', '°'], + 'winddir_avg10m': ['Wind Dir Avg 10m', '°'], + 'winddir_avg2m': ['Wind Dir Avg 2m', 'mph'], + 'windgustdir': ['Gust Dir', '°'], + 'windgustmph': ['Wind Gust', 'mph'], + 'windspdmph_avg10m': ['Wind Avg 10m', 'mph'], + 'windspdmph_avg2m': ['Wind Avg 2m', 'mph'], + 'windspeedmph': ['Wind Speed', 'mph'], + 'yearlyrainin': ['Yearly Rain', 'in'], +} + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: + vol.Schema({ + vol.Required(CONF_APP_KEY): + cv.string, + vol.Required(CONF_API_KEY): + cv.string, + vol.Optional( + CONF_MONITORED_CONDITIONS, default=list(SENSOR_TYPES)): + vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), + vol.Optional(CONF_UNIT_SYSTEM): + vol.In([UNITS_SI, UNITS_US]), + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the Ambient PWS component.""" + hass.data[DOMAIN] = {} + hass.data[DOMAIN][DATA_CLIENT] = {} + + if DOMAIN not in config: + return True + + conf = config[DOMAIN] + + if conf[CONF_APP_KEY] in configured_instances(hass): + return True + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={'source': SOURCE_IMPORT}, data=conf)) + + return True + + +async def async_setup_entry(hass, config_entry): + """Set up the Ambient PWS as config entry.""" + from aioambient import Client + from aioambient.errors import WebsocketConnectionError + + session = aiohttp_client.async_get_clientsession(hass) + + try: + ambient = AmbientStation( + hass, + config_entry, + Client( + config_entry.data[CONF_API_KEY], + config_entry.data[CONF_APP_KEY], session), + config_entry.data.get( + CONF_MONITORED_CONDITIONS, list(SENSOR_TYPES)), + config_entry.data.get(CONF_UNIT_SYSTEM)) + hass.loop.create_task(ambient.ws_connect()) + hass.data[DOMAIN][DATA_CLIENT][config_entry.entry_id] = ambient + except WebsocketConnectionError as err: + _LOGGER.error('Config entry failed: %s', err) + raise ConfigEntryNotReady + + hass.bus.async_listen_once( + EVENT_HOMEASSISTANT_STOP, ambient.client.websocket.disconnect()) + + return True + + +async def async_unload_entry(hass, config_entry): + """Unload an Ambient PWS config entry.""" + ambient = hass.data[DOMAIN][DATA_CLIENT].pop(config_entry.entry_id) + hass.async_create_task(ambient.ws_disconnect()) + + await hass.config_entries.async_forward_entry_unload( + config_entry, 'sensor') + + return True + + +class AmbientStation: + """Define a class to handle the Ambient websocket.""" + + def __init__( + self, hass, config_entry, client, monitored_conditions, + unit_system): + """Initialize.""" + self._config_entry = config_entry + self._hass = hass + self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY + self.client = client + self.monitored_conditions = monitored_conditions + self.stations = {} + self.unit_system = unit_system + + async def ws_connect(self): + """Register handlers and connect to the websocket.""" + from aioambient.errors import WebsocketError + + def on_connect(): + """Define a handler to fire when the websocket is connected.""" + _LOGGER.info('Connected to websocket') + + def on_data(data): + """Define a handler to fire when the data is received.""" + mac_address = data['macAddress'] + if data != self.stations[mac_address][ATTR_LAST_DATA]: + _LOGGER.debug('New data received: %s', data) + self.stations[mac_address][ATTR_LAST_DATA] = data + async_dispatcher_send(self._hass, TOPIC_UPDATE) + + def on_disconnect(): + """Define a handler to fire when the websocket is disconnected.""" + _LOGGER.info('Disconnected from websocket') + + def on_subscribed(data): + """Define a handler to fire when the subscription is set.""" + for station in data['devices']: + if station['macAddress'] in self.stations: + continue + + _LOGGER.debug('New station subscription: %s', data) + + self.stations[station['macAddress']] = { + ATTR_LAST_DATA: station['lastData'], + ATTR_LOCATION: station['info']['location'], + ATTR_NAME: station['info']['name'], + } + + self._hass.async_create_task( + self._hass.config_entries.async_forward_entry_setup( + self._config_entry, 'sensor')) + + self._ws_reconnect_delay = DEFAULT_SOCKET_MIN_RETRY + + self.client.websocket.on_connect(on_connect) + self.client.websocket.on_data(on_data) + self.client.websocket.on_disconnect(on_disconnect) + self.client.websocket.on_subscribed(on_subscribed) + + try: + await self.client.websocket.connect() + except WebsocketError as err: + _LOGGER.error("Error with the websocket connection: %s", err) + + self._ws_reconnect_delay = min( + 2 * self._ws_reconnect_delay, 480) + + async_call_later( + self._hass, self._ws_reconnect_delay, self.ws_connect) + + async def ws_disconnect(self): + """Disconnect from the websocket.""" + await self.client.websocket.disconnect() diff --git a/homeassistant/components/ambient_station/config_flow.py b/homeassistant/components/ambient_station/config_flow.py new file mode 100644 index 00000000000..56e747ce5e0 --- /dev/null +++ b/homeassistant/components/ambient_station/config_flow.py @@ -0,0 +1,72 @@ +"""Config flow to configure the Ambient PWS component.""" + +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_API_KEY +from homeassistant.core import callback +from homeassistant.helpers import aiohttp_client + +from .const import CONF_APP_KEY, DOMAIN + + +@callback +def configured_instances(hass): + """Return a set of configured Ambient PWS instances.""" + return set( + entry.data[CONF_APP_KEY] + for entry in hass.config_entries.async_entries(DOMAIN)) + + +@config_entries.HANDLERS.register(DOMAIN) +class AmbientStationFlowHandler(config_entries.ConfigFlow): + """Handle an Ambient PWS config flow.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_PUSH + + async def _show_form(self, errors=None): + """Show the form to the user.""" + data_schema = vol.Schema({ + vol.Required(CONF_API_KEY): str, + vol.Required(CONF_APP_KEY): str, + }) + + return self.async_show_form( + step_id='user', + data_schema=data_schema, + errors=errors if errors else {}, + ) + + async def async_step_import(self, import_config): + """Import a config entry from configuration.yaml.""" + return await self.async_step_user(import_config) + + async def async_step_user(self, user_input=None): + """Handle the start of the config flow.""" + from aioambient import Client + from aioambient.errors import AmbientError + + if not user_input: + return await self._show_form() + + if user_input[CONF_APP_KEY] in configured_instances(self.hass): + return await self._show_form({CONF_APP_KEY: 'identifier_exists'}) + + session = aiohttp_client.async_get_clientsession(self.hass) + client = Client( + user_input[CONF_API_KEY], user_input[CONF_APP_KEY], session) + + try: + devices = await client.api.get_devices() + except AmbientError: + return await self._show_form({'base': 'invalid_key'}) + + if not devices: + return await self._show_form({'base': 'no_devices'}) + + # The Application Key (which identifies each config entry) is too long + # to show nicely in the UI, so we take the first 12 characters (similar + # to how GitHub does it): + return self.async_create_entry( + title=user_input[CONF_APP_KEY][:12], data=user_input) diff --git a/homeassistant/components/ambient_station/const.py b/homeassistant/components/ambient_station/const.py new file mode 100644 index 00000000000..df2c5462e66 --- /dev/null +++ b/homeassistant/components/ambient_station/const.py @@ -0,0 +1,13 @@ +"""Define constants for the Ambient PWS component.""" +DOMAIN = 'ambient_station' + +ATTR_LAST_DATA = 'last_data' + +CONF_APP_KEY = 'app_key' + +DATA_CLIENT = 'data_client' + +TOPIC_UPDATE = 'update' + +UNITS_SI = 'si' +UNITS_US = 'us' diff --git a/homeassistant/components/ambient_station/sensor.py b/homeassistant/components/ambient_station/sensor.py new file mode 100644 index 00000000000..d2d89233472 --- /dev/null +++ b/homeassistant/components/ambient_station/sensor.py @@ -0,0 +1,115 @@ +""" +Support for Ambient Weather Station Service. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.ambient_station/ +""" +import logging + +from homeassistant.components.ambient_station import SENSOR_TYPES +from homeassistant.helpers.entity import Entity +from homeassistant.const import ATTR_NAME +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect + +from .const import ( + ATTR_LAST_DATA, DATA_CLIENT, DOMAIN, TOPIC_UPDATE, UNITS_SI, UNITS_US) + +DEPENDENCIES = ['ambient_station'] +_LOGGER = logging.getLogger(__name__) + +UNIT_SYSTEM = {UNITS_US: 0, UNITS_SI: 1} + + +async def async_setup_platform( + hass, config, async_add_entities, discovery_info=None): + """Set up an Ambient PWS sensor based on existing config.""" + pass + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up an Ambient PWS sensor based on a config entry.""" + ambient = hass.data[DOMAIN][DATA_CLIENT][entry.entry_id] + + if ambient.unit_system: + sys_units = ambient.unit_system + elif hass.config.units.is_metric: + sys_units = UNITS_SI + else: + sys_units = UNITS_US + + sensor_list = [] + for mac_address, station in ambient.stations.items(): + for condition in ambient.monitored_conditions: + name, unit = SENSOR_TYPES[condition] + if isinstance(unit, list): + unit = unit[UNIT_SYSTEM[sys_units]] + + sensor_list.append( + AmbientWeatherSensor( + ambient, mac_address, station[ATTR_NAME], condition, name, + unit)) + + async_add_entities(sensor_list, True) + + +class AmbientWeatherSensor(Entity): + """Define an Ambient sensor.""" + + def __init__( + self, ambient, mac_address, station_name, sensor_type, sensor_name, + units): + """Initialize the sensor.""" + self._ambient = ambient + self._async_unsub_dispatcher_connect = None + self._mac_address = mac_address + self._sensor_name = sensor_name + self._sensor_type = sensor_type + self._state = None + self._station_name = station_name + self._units = units + + @property + def name(self): + """Return the name of the sensor.""" + return '{0}_{1}'.format(self._station_name, self._sensor_name) + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def state(self): + """Return the state of the sensor.""" + return self._state + + @property + def unit_of_measurement(self): + """Return the unit of measurement.""" + return self._units + + @property + def unique_id(self): + """Return a unique, unchanging string that represents this sensor.""" + return '{0}_{1}'.format(self._mac_address, self._sensor_name) + + async def async_added_to_hass(self): + """Register callbacks.""" + @callback + def update(): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + self._async_unsub_dispatcher_connect = async_dispatcher_connect( + self.hass, TOPIC_UPDATE, update) + + async def async_will_remove_from_hass(self): + """Disconnect dispatcher listener when removed.""" + if self._async_unsub_dispatcher_connect: + self._async_unsub_dispatcher_connect() + + async def async_update(self): + """Fetch new state data for the sensor.""" + self._state = self._ambient.stations[ + self._mac_address][ATTR_LAST_DATA].get(self._sensor_type) diff --git a/homeassistant/components/ambient_station/strings.json b/homeassistant/components/ambient_station/strings.json new file mode 100644 index 00000000000..657b3477bb2 --- /dev/null +++ b/homeassistant/components/ambient_station/strings.json @@ -0,0 +1,19 @@ +{ + "config": { + "title": "Ambient PWS", + "step": { + "user": { + "title": "Fill in your information", + "data": { + "api_key": "API Key", + "app_key": "Application Key" + } + } + }, + "error": { + "identifier_exists": "Application Key and/or API Key already registered", + "invalid_key": "Invalid API Key and/or Application Key", + "no_devices": "No devices found in account" + } + } +} diff --git a/homeassistant/components/sensor/ambient_station.py b/homeassistant/components/sensor/ambient_station.py deleted file mode 100644 index bc44f83d764..00000000000 --- a/homeassistant/components/sensor/ambient_station.py +++ /dev/null @@ -1,212 +0,0 @@ -""" -Support for Ambient Weather Station Service. - -For more details about this platform, please refer to the documentation at -https://home-assistant.io/components/sensor.ambient_station/ -""" - -import asyncio -from datetime import timedelta -import logging - -import voluptuous as vol - -from homeassistant.components.sensor import PLATFORM_SCHEMA -from homeassistant.const import CONF_API_KEY, CONF_MONITORED_CONDITIONS -import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.entity import Entity -from homeassistant.util import Throttle - -REQUIREMENTS = ['ambient_api==1.5.2'] - -CONF_APP_KEY = 'app_key' - -SENSOR_NAME = 0 -SENSOR_UNITS = 1 - -CONF_UNITS = 'units' -UNITS_US = 'us' -UNITS_SI = 'si' -UNIT_SYSTEM = {UNITS_US: 0, UNITS_SI: 1} - -SCAN_INTERVAL = timedelta(seconds=300) - -SENSOR_TYPES = { - 'winddir': ['Wind Dir', '°'], - 'windspeedmph': ['Wind Speed', 'mph'], - 'windgustmph': ['Wind Gust', 'mph'], - 'maxdailygust': ['Max Gust', 'mph'], - 'windgustdir': ['Gust Dir', '°'], - 'windspdmph_avg2m': ['Wind Avg 2m', 'mph'], - 'winddir_avg2m': ['Wind Dir Avg 2m', 'mph'], - 'windspdmph_avg10m': ['Wind Avg 10m', 'mph'], - 'winddir_avg10m': ['Wind Dir Avg 10m', '°'], - 'humidity': ['Humidity', '%'], - 'humidityin': ['Humidity In', '%'], - 'tempf': ['Temp', ['°F', '°C']], - 'tempinf': ['Inside Temp', ['°F', '°C']], - 'battout': ['Battery', ''], - 'hourlyrainin': ['Hourly Rain Rate', 'in/hr'], - 'dailyrainin': ['Daily Rain', 'in'], - '24hourrainin': ['24 Hr Rain', 'in'], - 'weeklyrainin': ['Weekly Rain', 'in'], - 'monthlyrainin': ['Monthly Rain', 'in'], - 'yearlyrainin': ['Yearly Rain', 'in'], - 'eventrainin': ['Event Rain', 'in'], - 'totalrainin': ['Lifetime Rain', 'in'], - 'baromrelin': ['Rel Pressure', 'inHg'], - 'baromabsin': ['Abs Pressure', 'inHg'], - 'uv': ['uv', 'Index'], - 'solarradiation': ['Solar Rad', 'W/m^2'], - 'co2': ['co2', 'ppm'], - 'lastRain': ['Last Rain', ''], - 'dewPoint': ['Dew Point', ['°F', '°C']], - 'feelsLike': ['Feels Like', ['°F', '°C']], -} - -_LOGGER = logging.getLogger(__name__) - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ - vol.Required(CONF_API_KEY): cv.string, - vol.Required(CONF_APP_KEY): cv.string, - vol.Required(CONF_MONITORED_CONDITIONS): - vol.All(cv.ensure_list, [vol.In(SENSOR_TYPES)]), - vol.Optional(CONF_UNITS): vol.In([UNITS_SI, UNITS_US]), -}) - - -def setup_platform(hass, config, add_entities, discovery_info=None): - """Initialze each sensor platform for each monitored condition.""" - api_key = config[CONF_API_KEY] - app_key = config[CONF_APP_KEY] - station_data = AmbientStationData(hass, api_key, app_key) - if not station_data.connect_success: - _LOGGER.error("Could not connect to weather station API") - return - - sensor_list = [] - - if CONF_UNITS in config: - sys_units = config[CONF_UNITS] - elif hass.config.units.is_metric: - sys_units = UNITS_SI - else: - sys_units = UNITS_US - - for condition in config[CONF_MONITORED_CONDITIONS]: - # create a sensor object for each monitored condition - sensor_params = SENSOR_TYPES[condition] - name = sensor_params[SENSOR_NAME] - units = sensor_params[SENSOR_UNITS] - if isinstance(units, list): - units = sensor_params[SENSOR_UNITS][UNIT_SYSTEM[sys_units]] - - sensor_list.append(AmbientWeatherSensor(station_data, condition, - name, units)) - - add_entities(sensor_list) - - -class AmbientWeatherSensor(Entity): - """Representation of a Sensor.""" - - def __init__(self, station_data, condition, name, units): - """Initialize the sensor.""" - self._state = None - self.station_data = station_data - self._condition = condition - self._name = name - self._units = units - - @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def state(self): - """Return the state of the sensor.""" - return self._state - - @property - def unit_of_measurement(self): - """Return the unit of measurement.""" - return self._units - - async def async_update(self): - """Fetch new state data for the sensor. - - This is the only method that should fetch new data for Home Assistant. - """ - _LOGGER.debug("Getting data for sensor: %s", self._name) - data = await self.station_data.get_data() - if data is None: - # update likely got throttled and returned None, so use the cached - # data from the station_data object - self._state = self.station_data.data[self._condition] - else: - if self._condition in data: - self._state = data[self._condition] - else: - _LOGGER.warning("%s sensor data not available from the " - "station", self._condition) - - _LOGGER.debug("Sensor: %s | Data: %s", self._name, self._state) - - -class AmbientStationData: - """Class to interface with ambient-api library.""" - - def __init__(self, hass, api_key, app_key): - """Initialize station data object.""" - self.hass = hass - self._api_keys = { - 'AMBIENT_ENDPOINT': - 'https://api.ambientweather.net/v1', - 'AMBIENT_API_KEY': api_key, - 'AMBIENT_APPLICATION_KEY': app_key, - 'log_level': 'DEBUG' - } - - self.data = None - self._station = None - self._api = None - self._devices = None - self.connect_success = False - - self.get_data = Throttle(SCAN_INTERVAL)(self.async_update) - self._connect_api() # attempt to connect to API - - async def async_update(self): - """Get new data.""" - # refresh API connection since servers turn over nightly - _LOGGER.debug("Getting new data from server") - new_data = None - await self.hass.async_add_executor_job(self._connect_api) - await asyncio.sleep(2) # need minimum 2 seconds between API calls - if self._station is not None: - data = await self.hass.async_add_executor_job( - self._station.get_data) - if data is not None: - new_data = data[0] - self.data = new_data - else: - _LOGGER.debug("data is None type") - else: - _LOGGER.debug("Station is None type") - - return new_data - - def _connect_api(self): - """Connect to the API and capture new data.""" - from ambient_api.ambientapi import AmbientAPI - - self._api = AmbientAPI(**self._api_keys) - self._devices = self._api.get_devices() - - if self._devices: - self._station = self._devices[0] - if self._station is not None: - self.connect_success = True - else: - _LOGGER.debug("No station devices available") diff --git a/homeassistant/config_entries.py b/homeassistant/config_entries.py index 00b5d797682..159f5651c31 100644 --- a/homeassistant/config_entries.py +++ b/homeassistant/config_entries.py @@ -135,6 +135,7 @@ SOURCE_IMPORT = 'import' HANDLERS = Registry() # Components that have config flows. In future we will auto-generate this list. FLOWS = [ + 'ambient_station', 'cast', 'daikin', 'deconz', diff --git a/requirements_all.txt b/requirements_all.txt index 58c9e81d272..ca4459f66bc 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -86,6 +86,9 @@ abodepy==0.15.0 # homeassistant.components.media_player.frontier_silicon afsapi==0.0.4 +# homeassistant.components.ambient_station +aioambient==0.1.0 + # homeassistant.components.asuswrt aioasuswrt==1.1.18 @@ -141,9 +144,6 @@ alarmdecoder==1.13.2 # homeassistant.components.sensor.alpha_vantage alpha_vantage==2.1.0 -# homeassistant.components.sensor.ambient_station -ambient_api==1.5.2 - # homeassistant.components.amcrest amcrest==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f16780d4c2..2dbd2760c7d 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -30,6 +30,9 @@ PyTransportNSW==0.1.1 # homeassistant.components.notify.yessssms YesssSMS==0.2.3 +# homeassistant.components.ambient_station +aioambient==0.1.0 + # homeassistant.components.device_tracker.automatic aioautomatic==0.6.5 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 8817ee61e8f..79ba3f8c342 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -36,6 +36,7 @@ COMMENT_REQUIREMENTS = ( ) TEST_REQUIREMENTS = ( + 'aioambient', 'aioautomatic', 'aiohttp_cors', 'aiohue', diff --git a/tests/components/ambient_station/__init__.py b/tests/components/ambient_station/__init__.py new file mode 100644 index 00000000000..1de98ab57bb --- /dev/null +++ b/tests/components/ambient_station/__init__.py @@ -0,0 +1 @@ +"""Define tests for the Ambient PWS component.""" diff --git a/tests/components/ambient_station/test_config_flow.py b/tests/components/ambient_station/test_config_flow.py new file mode 100644 index 00000000000..a988208e4a0 --- /dev/null +++ b/tests/components/ambient_station/test_config_flow.py @@ -0,0 +1,130 @@ +"""Define tests for the Ambient PWS config flow.""" +import json + +import aioambient +import pytest + +from homeassistant import data_entry_flow +from homeassistant.components.ambient_station import ( + CONF_APP_KEY, DOMAIN, config_flow) +from homeassistant.const import CONF_API_KEY + +from tests.common import ( + load_fixture, MockConfigEntry, MockDependency, mock_coro) + + +@pytest.fixture +def get_devices_response(): + """Define a fixture for a successful /devices response.""" + return mock_coro() + + +@pytest.fixture +def mock_aioambient(get_devices_response): + """Mock the aioambient library.""" + with MockDependency('aioambient') as mock_aioambient_: + mock_aioambient_.Client( + ).api.get_devices.return_value = get_devices_response + yield mock_aioambient_ + + +async def test_duplicate_error(hass): + """Test that errors are shown when duplicates are added.""" + conf = { + CONF_API_KEY: '12345abcde12345abcde', + CONF_APP_KEY: '67890fghij67890fghij', + } + + MockConfigEntry(domain=DOMAIN, data=conf).add_to_hass(hass) + flow = config_flow.AmbientStationFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {CONF_APP_KEY: 'identifier_exists'} + + +@pytest.mark.parametrize( + 'get_devices_response', + [mock_coro(exception=aioambient.errors.AmbientError)]) +async def test_invalid_api_key(hass, mock_aioambient): + """Test that an invalid API/App Key throws an error.""" + conf = { + CONF_API_KEY: '12345abcde12345abcde', + CONF_APP_KEY: '67890fghij67890fghij', + } + + flow = config_flow.AmbientStationFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {'base': 'invalid_key'} + + +@pytest.mark.parametrize('get_devices_response', [mock_coro(return_value=[])]) +async def test_no_devices(hass, mock_aioambient): + """Test that an account with no associated devices throws an error.""" + conf = { + CONF_API_KEY: '12345abcde12345abcde', + CONF_APP_KEY: '67890fghij67890fghij', + } + + flow = config_flow.AmbientStationFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['errors'] == {'base': 'no_devices'} + + +async def test_show_form(hass): + """Test that the form is served with no input.""" + flow = config_flow.AmbientStationFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=None) + + assert result['type'] == data_entry_flow.RESULT_TYPE_FORM + assert result['step_id'] == 'user' + + +@pytest.mark.parametrize( + 'get_devices_response', + [mock_coro(return_value=json.loads(load_fixture('ambient_devices.json')))]) +async def test_step_import(hass, mock_aioambient): + """Test that the import step works.""" + conf = { + CONF_API_KEY: '12345abcde12345abcde', + CONF_APP_KEY: '67890fghij67890fghij', + } + + flow = config_flow.AmbientStationFlowHandler() + flow.hass = hass + + result = await flow.async_step_import(import_config=conf) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '67890fghij67' + assert result['data'] == { + CONF_API_KEY: '12345abcde12345abcde', + CONF_APP_KEY: '67890fghij67890fghij', + } + + +@pytest.mark.parametrize( + 'get_devices_response', + [mock_coro(return_value=json.loads(load_fixture('ambient_devices.json')))]) +async def test_step_user(hass, mock_aioambient): + """Test that the user step works.""" + conf = { + CONF_API_KEY: '12345abcde12345abcde', + CONF_APP_KEY: '67890fghij67890fghij', + } + + flow = config_flow.AmbientStationFlowHandler() + flow.hass = hass + + result = await flow.async_step_user(user_input=conf) + assert result['type'] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result['title'] == '67890fghij67' + assert result['data'] == { + CONF_API_KEY: '12345abcde12345abcde', + CONF_APP_KEY: '67890fghij67890fghij', + } diff --git a/tests/fixtures/ambient_devices.json b/tests/fixtures/ambient_devices.json new file mode 100644 index 00000000000..cd5edc21cb0 --- /dev/null +++ b/tests/fixtures/ambient_devices.json @@ -0,0 +1,15 @@ +[{ + "macAddress": "12:34:56:78:90:AB", + "lastData": { + "dateutc": 1546889640000, + "baromrelin": 30.09, + "baromabsin": 24.61, + "tempinf": 68.9, + "humidityin": 30, + "date": "2019-01-07T19:34:00.000Z" + }, + "info": { + "name": "Home", + "location": "Home" + } +}] -- GitLab