diff --git a/.coveragerc b/.coveragerc index 5fd43d5aec7e9de759531e5ef78e55ee2257c766..b0f85b14c0678c9f76c5bfd44bfcd97a7f3e188d 100644 --- a/.coveragerc +++ b/.coveragerc @@ -578,6 +578,7 @@ omit = homeassistant/components/sensor/fitbit.py homeassistant/components/sensor/fixer.py homeassistant/components/sensor/folder.py + homeassistant/components/sensor/foobot.py homeassistant/components/sensor/fritzbox_callmonitor.py homeassistant/components/sensor/fritzbox_netmonitor.py homeassistant/components/sensor/gearbest.py diff --git a/homeassistant/components/sensor/foobot.py b/homeassistant/components/sensor/foobot.py new file mode 100644 index 0000000000000000000000000000000000000000..8f65a3358726712b6846f1197864c957c051b6d9 --- /dev/null +++ b/homeassistant/components/sensor/foobot.py @@ -0,0 +1,158 @@ +""" +Support for the Foobot indoor air quality monitor. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.foobot/ +""" +import asyncio +import logging +from datetime import timedelta + +import aiohttp +import voluptuous as vol + +import homeassistant.helpers.config_validation as cv +from homeassistant.exceptions import PlatformNotReady +from homeassistant.const import ( + ATTR_TIME, ATTR_TEMPERATURE, CONF_TOKEN, CONF_USERNAME, TEMP_CELSIUS) +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.config_validation import PLATFORM_SCHEMA +from homeassistant.helpers.entity import Entity +from homeassistant.util import Throttle + + +REQUIREMENTS = ['foobot_async==0.3.0'] + +_LOGGER = logging.getLogger(__name__) + +ATTR_HUMIDITY = 'humidity' +ATTR_PM2_5 = 'PM2.5' +ATTR_CARBON_DIOXIDE = 'CO2' +ATTR_VOLATILE_ORGANIC_COMPOUNDS = 'VOC' +ATTR_FOOBOT_INDEX = 'index' + +SENSOR_TYPES = {'time': [ATTR_TIME, 's'], + 'pm': [ATTR_PM2_5, 'µg/m3', 'mdi:cloud'], + 'tmp': [ATTR_TEMPERATURE, TEMP_CELSIUS, 'mdi:thermometer'], + 'hum': [ATTR_HUMIDITY, '%', 'mdi:water-percent'], + 'co2': [ATTR_CARBON_DIOXIDE, 'ppm', + 'mdi:periodic-table-co2'], + 'voc': [ATTR_VOLATILE_ORGANIC_COMPOUNDS, 'ppb', + 'mdi:cloud'], + 'allpollu': [ATTR_FOOBOT_INDEX, '%', 'mdi:percent']} + +SCAN_INTERVAL = timedelta(minutes=10) +PARALLEL_UPDATES = 1 + +TIMEOUT = 10 + +PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend({ + vol.Required(CONF_TOKEN): cv.string, + vol.Required(CONF_USERNAME): cv.string, +}) + + +async def async_setup_platform(hass, config, async_add_devices, + discovery_info=None): + """Set up the devices associated with the account.""" + from foobot_async import FoobotClient + + token = config.get(CONF_TOKEN) + username = config.get(CONF_USERNAME) + + client = FoobotClient(token, username, + async_get_clientsession(hass), + timeout=TIMEOUT) + dev = [] + try: + devices = await client.get_devices() + _LOGGER.debug("The following devices were found: %s", devices) + for device in devices: + foobot_data = FoobotData(client, device['uuid']) + for sensor_type in SENSOR_TYPES: + if sensor_type == 'time': + continue + foobot_sensor = FoobotSensor(foobot_data, device, sensor_type) + dev.append(foobot_sensor) + except (aiohttp.client_exceptions.ClientConnectorError, + asyncio.TimeoutError, FoobotClient.TooManyRequests, + FoobotClient.InternalError): + _LOGGER.exception('Failed to connect to foobot servers.') + raise PlatformNotReady + except FoobotClient.ClientError: + _LOGGER.error('Failed to fetch data from foobot servers.') + return + async_add_devices(dev, True) + + +class FoobotSensor(Entity): + """Implementation of a Foobot sensor.""" + + def __init__(self, data, device, sensor_type): + """Initialize the sensor.""" + self._uuid = device['uuid'] + self.foobot_data = data + self._name = 'Foobot {} {}'.format(device['name'], + SENSOR_TYPES[sensor_type][0]) + self.type = sensor_type + self._unit_of_measurement = SENSOR_TYPES[sensor_type][1] + + @property + def name(self): + """Return the name of the sensor.""" + return self._name + + @property + def icon(self): + """Icon to use in the frontend.""" + return SENSOR_TYPES[self.type][2] + + @property + def state(self): + """Return the state of the device.""" + try: + data = self.foobot_data.data[self.type] + except(KeyError, TypeError): + data = None + return data + + @property + def unique_id(self): + """Return the unique id of this entity.""" + return "{}_{}".format(self._uuid, self.type) + + @property + def unit_of_measurement(self): + """Return the unit of measurement of this entity.""" + return self._unit_of_measurement + + async def async_update(self): + """Get the latest data.""" + await self.foobot_data.async_update() + + +class FoobotData(Entity): + """Get data from Foobot API.""" + + def __init__(self, client, uuid): + """Initialize the data object.""" + self._client = client + self._uuid = uuid + self.data = {} + + @Throttle(SCAN_INTERVAL) + async def async_update(self): + """Get the data from Foobot API.""" + interval = SCAN_INTERVAL.total_seconds() + try: + response = await self._client.get_last_data(self._uuid, + interval, + interval + 1) + except (aiohttp.client_exceptions.ClientConnectorError, + asyncio.TimeoutError, self._client.TooManyRequests, + self._client.InternalError): + _LOGGER.debug("Couldn't fetch data") + return False + _LOGGER.debug("The data response is: %s", response) + self.data = {k: round(v, 1) for k, v in response[0].items()} + return True diff --git a/requirements_all.txt b/requirements_all.txt index 608618eb166c3672987dd36ff8ec5704eeec1c84..f2919eb9bc31598c9211b13749e146120ca3ba5f 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -287,6 +287,9 @@ fixerio==0.1.1 # homeassistant.components.light.flux_led flux_led==0.21 +# homeassistant.components.sensor.foobot +foobot_async==0.3.0 + # homeassistant.components.notify.free_mobile freesms==0.1.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 9def3a7b301b048fecc3d5349e97bdd646f36138..69b56eabc5e9704d136b80de28157a6907d3b140 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -62,6 +62,9 @@ evohomeclient==0.2.5 # homeassistant.components.sensor.geo_rss_events feedparser==5.2.1 +# homeassistant.components.sensor.foobot +foobot_async==0.3.0 + # homeassistant.components.tts.google gTTS-token==1.1.1 diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index a7704088e265196c8731ce321445f0e56d38f710..d8fc7b1ed604168c15ab8aa1b662c7b98729f5ef 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -47,6 +47,7 @@ TEST_REQUIREMENTS = ( 'ephem', 'evohomeclient', 'feedparser', + 'foobot_async', 'gTTS-token', 'HAP-python', 'ha-ffmpeg', diff --git a/tests/components/sensor/test_foobot.py b/tests/components/sensor/test_foobot.py new file mode 100644 index 0000000000000000000000000000000000000000..322f2b3f2a893aa2883f5541da8bcad07fba3ebf --- /dev/null +++ b/tests/components/sensor/test_foobot.py @@ -0,0 +1,81 @@ +"""The tests for the Foobot sensor platform.""" + +import re +import asyncio +from unittest.mock import MagicMock +import pytest + + +import homeassistant.components.sensor as sensor +from homeassistant.components.sensor import foobot +from homeassistant.const import (TEMP_CELSIUS) +from homeassistant.exceptions import PlatformNotReady +from homeassistant.setup import async_setup_component +from tests.common import load_fixture + +VALID_CONFIG = { + 'platform': 'foobot', + 'token': 'adfdsfasd', + 'username': 'example@example.com', +} + + +async def test_default_setup(hass, aioclient_mock): + """Test the default setup.""" + aioclient_mock.get(re.compile('api.foobot.io/v2/owner/.*'), + text=load_fixture('foobot_devices.json')) + aioclient_mock.get(re.compile('api.foobot.io/v2/device/.*'), + text=load_fixture('foobot_data.json')) + assert await async_setup_component(hass, sensor.DOMAIN, + {'sensor': VALID_CONFIG}) + + metrics = {'co2': ['1232.0', 'ppm'], + 'temperature': ['21.1', TEMP_CELSIUS], + 'humidity': ['49.5', '%'], + 'pm25': ['144.8', 'µg/m3'], + 'voc': ['340.7', 'ppb'], + 'index': ['138.9', '%']} + + for name, value in metrics.items(): + state = hass.states.get('sensor.foobot_happybot_%s' % name) + assert state.state == value[0] + assert state.attributes.get('unit_of_measurement') == value[1] + + +async def test_setup_timeout_error(hass, aioclient_mock): + """Expected failures caused by a timeout in API response.""" + fake_async_add_devices = MagicMock() + + aioclient_mock.get(re.compile('api.foobot.io/v2/owner/.*'), + exc=asyncio.TimeoutError()) + with pytest.raises(PlatformNotReady): + await foobot.async_setup_platform(hass, {'sensor': VALID_CONFIG}, + fake_async_add_devices) + + +async def test_setup_permanent_error(hass, aioclient_mock): + """Expected failures caused by permanent errors in API response.""" + fake_async_add_devices = MagicMock() + + errors = [400, 401, 403] + for error in errors: + aioclient_mock.get(re.compile('api.foobot.io/v2/owner/.*'), + status=error) + result = await foobot.async_setup_platform(hass, + {'sensor': VALID_CONFIG}, + fake_async_add_devices) + assert result is None + + +async def test_setup_temporary_error(hass, aioclient_mock): + """Expected failures caused by temporary errors in API response.""" + fake_async_add_devices = MagicMock() + + errors = [429, 500] + for error in errors: + aioclient_mock.get(re.compile('api.foobot.io/v2/owner/.*'), + status=error) + with pytest.raises(PlatformNotReady): + await foobot.async_setup_platform(hass, + {'sensor': VALID_CONFIG}, + fake_async_add_devices) diff --git a/tests/fixtures/foobot_data.json b/tests/fixtures/foobot_data.json new file mode 100644 index 0000000000000000000000000000000000000000..93518614c42e5d8da42b47289e1400b16ae358de --- /dev/null +++ b/tests/fixtures/foobot_data.json @@ -0,0 +1,34 @@ +{ + "uuid": "32463564765421243", + "start": 1518134963, + "end": 1518134963, + "sensors": [ + "time", + "pm", + "tmp", + "hum", + "co2", + "voc", + "allpollu" + ], + "units": [ + "s", + "ugm3", + "C", + "pc", + "ppm", + "ppb", + "%" + ], + "datapoints": [ + [ + 1518134963, + 144.76668, + 21.064333, + 49.474, + 1232.0, + 340.66666, + 138.93651 + ] + ] +} diff --git a/tests/fixtures/foobot_devices.json b/tests/fixtures/foobot_devices.json new file mode 100644 index 0000000000000000000000000000000000000000..fffc8e151ccdd6c461c992d058520fe67552a325 --- /dev/null +++ b/tests/fixtures/foobot_devices.json @@ -0,0 +1,8 @@ +[ + { + "uuid": "231425657665645342", + "userId": 6545342, + "mac": "A2D3F1", + "name": "Happybot" + } +]