From bdea9e1333afbd849ffb51b65461d10d9590590f Mon Sep 17 00:00:00 2001 From: Aaron Bach <bachya1208@gmail.com> Date: Wed, 1 Aug 2018 23:42:12 -0600 Subject: [PATCH] Add support for OpenUV binary sensors and sensors (#15769) * Initial commit * Adjusted ownership and coverage * Member-requested changes * Updated Ozone to a value, not an index * Verbiage update --- .coveragerc | 3 + CODEOWNERS | 2 + .../components/binary_sensor/openuv.py | 103 ++++++++++ homeassistant/components/openuv.py | 182 ++++++++++++++++++ homeassistant/components/sensor/openuv.py | 121 ++++++++++++ requirements_all.txt | 3 + 6 files changed, 414 insertions(+) create mode 100644 homeassistant/components/binary_sensor/openuv.py create mode 100644 homeassistant/components/openuv.py create mode 100644 homeassistant/components/sensor/openuv.py diff --git a/.coveragerc b/.coveragerc index bce7205dcd7..5dd2c66b56e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -215,6 +215,9 @@ omit = homeassistant/components/opencv.py homeassistant/components/*/opencv.py + homeassistant/components/openuv.py + homeassistant/components/*/openuv.py + homeassistant/components/pilight.py homeassistant/components/*/pilight.py diff --git a/CODEOWNERS b/CODEOWNERS index 556791b879c..53f577d02eb 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -98,6 +98,8 @@ homeassistant/components/konnected.py @heythisisnate homeassistant/components/*/konnected.py @heythisisnate homeassistant/components/matrix.py @tinloaf homeassistant/components/*/matrix.py @tinloaf +homeassistant/components/openuv.py @bachya +homeassistant/components/*/openuv.py @bachya homeassistant/components/qwikswitch.py @kellerza homeassistant/components/*/qwikswitch.py @kellerza homeassistant/components/rainmachine/* @bachya diff --git a/homeassistant/components/binary_sensor/openuv.py b/homeassistant/components/binary_sensor/openuv.py new file mode 100644 index 00000000000..3a2732d3be0 --- /dev/null +++ b/homeassistant/components/binary_sensor/openuv.py @@ -0,0 +1,103 @@ +""" +This platform provides binary sensors for OpenUV data. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/binary_sensor.openuv/ +""" +import logging + +from homeassistant.components.binary_sensor import BinarySensorDevice +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.openuv import ( + BINARY_SENSORS, DATA_PROTECTION_WINDOW, DOMAIN, TOPIC_UPDATE, + TYPE_PROTECTION_WINDOW, OpenUvEntity) +from homeassistant.util.dt import as_local, parse_datetime, utcnow + +DEPENDENCIES = ['openuv'] +_LOGGER = logging.getLogger(__name__) + +ATTR_PROTECTION_WINDOW_STARTING_TIME = 'start_time' +ATTR_PROTECTION_WINDOW_STARTING_UV = 'start_uv' +ATTR_PROTECTION_WINDOW_ENDING_TIME = 'end_time' +ATTR_PROTECTION_WINDOW_ENDING_UV = 'end_uv' + + +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): + """Set up the OpenUV binary sensor platform.""" + if discovery_info is None: + return + + openuv = hass.data[DOMAIN] + + binary_sensors = [] + for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + name, icon = BINARY_SENSORS[sensor_type] + binary_sensors.append( + OpenUvBinarySensor(openuv, sensor_type, name, icon)) + + async_add_devices(binary_sensors, True) + + +class OpenUvBinarySensor(OpenUvEntity, BinarySensorDevice): + """Define a binary sensor for OpenUV.""" + + def __init__(self, openuv, sensor_type, name, icon): + """Initialize the sensor.""" + super().__init__(openuv) + + self._icon = icon + self._latitude = openuv.client.latitude + self._longitude = openuv.client.longitude + self._name = name + self._sensor_type = sensor_type + self._state = None + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def is_on(self): + """Return the status of the sensor.""" + return self._state + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def unique_id(self) -> str: + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_{1}_{2}'.format( + self._latitude, self._longitude, self._sensor_type) + + @callback + def _update_data(self): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect( + self.hass, TOPIC_UPDATE, self._update_data) + + async def async_update(self): + """Update the state.""" + data = self.openuv.data[DATA_PROTECTION_WINDOW]['result'] + if self._sensor_type == TYPE_PROTECTION_WINDOW: + self._state = parse_datetime( + data['from_time']) <= utcnow() <= parse_datetime( + data['to_time']) + self._attrs.update({ + ATTR_PROTECTION_WINDOW_ENDING_TIME: + as_local(parse_datetime(data['to_time'])), + ATTR_PROTECTION_WINDOW_ENDING_UV: data['to_uv'], + ATTR_PROTECTION_WINDOW_STARTING_UV: data['from_uv'], + ATTR_PROTECTION_WINDOW_STARTING_TIME: + as_local(parse_datetime(data['from_time'])), + }) diff --git a/homeassistant/components/openuv.py b/homeassistant/components/openuv.py new file mode 100644 index 00000000000..dd038611ae9 --- /dev/null +++ b/homeassistant/components/openuv.py @@ -0,0 +1,182 @@ +""" +Support for data from openuv.io. + +For more details about this component, please refer to the documentation at +https://home-assistant.io/components/openuv/ +""" +import logging +from datetime import timedelta + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_ATTRIBUTION, CONF_API_KEY, CONF_BINARY_SENSORS, CONF_ELEVATION, + CONF_LATITUDE, CONF_LONGITUDE, CONF_MONITORED_CONDITIONS, + CONF_SCAN_INTERVAL, CONF_SENSORS) +from homeassistant.helpers import ( + aiohttp_client, config_validation as cv, discovery) +from homeassistant.helpers.dispatcher import async_dispatcher_send +from homeassistant.helpers.entity import Entity +from homeassistant.helpers.event import async_track_time_interval + +REQUIREMENTS = ['pyopenuv==1.0.1'] +_LOGGER = logging.getLogger(__name__) + +DOMAIN = 'openuv' + +DATA_PROTECTION_WINDOW = 'protection_window' +DATA_UV = 'uv' + +DEFAULT_ATTRIBUTION = 'Data provided by OpenUV' +DEFAULT_SCAN_INTERVAL = timedelta(minutes=30) + +NOTIFICATION_ID = 'openuv_notification' +NOTIFICATION_TITLE = 'OpenUV Component Setup' + +TOPIC_UPDATE = '{0}_data_update'.format(DOMAIN) + +TYPE_CURRENT_OZONE_LEVEL = 'current_ozone_level' +TYPE_CURRENT_UV_INDEX = 'current_uv_index' +TYPE_MAX_UV_INDEX = 'max_uv_index' +TYPE_PROTECTION_WINDOW = 'uv_protection_window' +TYPE_SAFE_EXPOSURE_TIME_1 = 'safe_exposure_time_type_1' +TYPE_SAFE_EXPOSURE_TIME_2 = 'safe_exposure_time_type_2' +TYPE_SAFE_EXPOSURE_TIME_3 = 'safe_exposure_time_type_3' +TYPE_SAFE_EXPOSURE_TIME_4 = 'safe_exposure_time_type_4' +TYPE_SAFE_EXPOSURE_TIME_5 = 'safe_exposure_time_type_5' +TYPE_SAFE_EXPOSURE_TIME_6 = 'safe_exposure_time_type_6' + +BINARY_SENSORS = { + TYPE_PROTECTION_WINDOW: ('Protection Window', 'mdi:sunglasses') +} + +BINARY_SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(BINARY_SENSORS)): + vol.All(cv.ensure_list, [vol.In(BINARY_SENSORS)]) +}) + +SENSORS = { + TYPE_CURRENT_OZONE_LEVEL: ( + 'Current Ozone Level', 'mdi:vector-triangle', 'du'), + TYPE_CURRENT_UV_INDEX: ('Current UV Index', 'mdi:weather-sunny', 'index'), + TYPE_MAX_UV_INDEX: ('Max UV Index', 'mdi:weather-sunny', 'index'), + TYPE_SAFE_EXPOSURE_TIME_1: ( + 'Skin Type 1 Safe Exposure Time', 'mdi:timer', 'minutes'), + TYPE_SAFE_EXPOSURE_TIME_2: ( + 'Skin Type 2 Safe Exposure Time', 'mdi:timer', 'minutes'), + TYPE_SAFE_EXPOSURE_TIME_3: ( + 'Skin Type 3 Safe Exposure Time', 'mdi:timer', 'minutes'), + TYPE_SAFE_EXPOSURE_TIME_4: ( + 'Skin Type 4 Safe Exposure Time', 'mdi:timer', 'minutes'), + TYPE_SAFE_EXPOSURE_TIME_5: ( + 'Skin Type 5 Safe Exposure Time', 'mdi:timer', 'minutes'), + TYPE_SAFE_EXPOSURE_TIME_6: ( + 'Skin Type 6 Safe Exposure Time', 'mdi:timer', 'minutes'), +} + +SENSOR_SCHEMA = vol.Schema({ + vol.Optional(CONF_MONITORED_CONDITIONS, default=list(SENSORS)): + vol.All(cv.ensure_list, [vol.In(SENSORS)]) +}) + +CONFIG_SCHEMA = vol.Schema({ + DOMAIN: vol.Schema({ + vol.Required(CONF_API_KEY): cv.string, + vol.Optional(CONF_ELEVATION): float, + vol.Optional(CONF_LATITUDE): cv.latitude, + vol.Optional(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_BINARY_SENSORS, default={}): BINARY_SENSOR_SCHEMA, + vol.Optional(CONF_SENSORS, default={}): SENSOR_SCHEMA, + vol.Optional(CONF_SCAN_INTERVAL, default=DEFAULT_SCAN_INTERVAL): + cv.time_period, + }) +}, extra=vol.ALLOW_EXTRA) + + +async def async_setup(hass, config): + """Set up the OpenUV component.""" + from pyopenuv import Client + from pyopenuv.errors import OpenUvError + + conf = config[DOMAIN] + api_key = conf[CONF_API_KEY] + elevation = conf.get(CONF_ELEVATION, hass.config.elevation) + latitude = conf.get(CONF_LATITUDE, hass.config.latitude) + longitude = conf.get(CONF_LONGITUDE, hass.config.longitude) + + try: + websession = aiohttp_client.async_get_clientsession(hass) + openuv = OpenUV( + Client( + api_key, latitude, longitude, websession, altitude=elevation), + conf[CONF_BINARY_SENSORS][CONF_MONITORED_CONDITIONS] + + conf[CONF_SENSORS][CONF_MONITORED_CONDITIONS]) + await openuv.async_update() + hass.data[DOMAIN] = openuv + except OpenUvError as err: + _LOGGER.error('An error occurred: %s', str(err)) + hass.components.persistent_notification.create( + 'Error: {0}<br />' + 'You will need to restart hass after fixing.' + ''.format(err), + title=NOTIFICATION_TITLE, + notification_id=NOTIFICATION_ID) + return False + + for component, schema in [ + ('binary_sensor', conf[CONF_BINARY_SENSORS]), + ('sensor', conf[CONF_SENSORS]), + ]: + hass.async_create_task( + discovery.async_load_platform( + hass, component, DOMAIN, schema, config)) + + async def refresh_sensors(event_time): + """Refresh OpenUV data.""" + _LOGGER.debug('Refreshing OpenUV data') + await openuv.async_update() + async_dispatcher_send(hass, TOPIC_UPDATE) + + async_track_time_interval(hass, refresh_sensors, conf[CONF_SCAN_INTERVAL]) + + return True + + +class OpenUV: + """Define a generic OpenUV object.""" + + def __init__(self, client, monitored_conditions): + """Initialize.""" + self._monitored_conditions = monitored_conditions + self.client = client + self.data = {} + + async def async_update(self): + """Update sensor/binary sensor data.""" + if TYPE_PROTECTION_WINDOW in self._monitored_conditions: + data = await self.client.uv_protection_window() + self.data[DATA_PROTECTION_WINDOW] = data + + if any(c in self._monitored_conditions for c in SENSORS): + data = await self.client.uv_index() + self.data[DATA_UV] = data + + +class OpenUvEntity(Entity): + """Define a generic OpenUV entity.""" + + def __init__(self, openuv): + """Initialize.""" + self._attrs = {ATTR_ATTRIBUTION: DEFAULT_ATTRIBUTION} + self._name = None + self.openuv = openuv + + @property + def device_state_attributes(self): + """Return the state attributes.""" + return self._attrs + + @property + def name(self): + """Return the name of the entity.""" + return self._name diff --git a/homeassistant/components/sensor/openuv.py b/homeassistant/components/sensor/openuv.py new file mode 100644 index 00000000000..b30c2908c40 --- /dev/null +++ b/homeassistant/components/sensor/openuv.py @@ -0,0 +1,121 @@ +""" +This platform provides sensors for OpenUV data. + +For more details about this platform, please refer to the documentation at +https://home-assistant.io/components/sensor.openuv/ +""" +import logging + +from homeassistant.const import CONF_MONITORED_CONDITIONS +from homeassistant.core import callback +from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.components.openuv import ( + DATA_UV, DOMAIN, SENSORS, TOPIC_UPDATE, TYPE_CURRENT_OZONE_LEVEL, + TYPE_CURRENT_UV_INDEX, TYPE_MAX_UV_INDEX, TYPE_SAFE_EXPOSURE_TIME_1, + TYPE_SAFE_EXPOSURE_TIME_2, TYPE_SAFE_EXPOSURE_TIME_3, + TYPE_SAFE_EXPOSURE_TIME_4, TYPE_SAFE_EXPOSURE_TIME_5, + TYPE_SAFE_EXPOSURE_TIME_6, OpenUvEntity) +from homeassistant.util.dt import as_local, parse_datetime + +DEPENDENCIES = ['openuv'] +_LOGGER = logging.getLogger(__name__) + +ATTR_MAX_UV_TIME = 'time' + +EXPOSURE_TYPE_MAP = { + TYPE_SAFE_EXPOSURE_TIME_1: 'st1', + TYPE_SAFE_EXPOSURE_TIME_2: 'st2', + TYPE_SAFE_EXPOSURE_TIME_3: 'st3', + TYPE_SAFE_EXPOSURE_TIME_4: 'st4', + TYPE_SAFE_EXPOSURE_TIME_5: 'st5', + TYPE_SAFE_EXPOSURE_TIME_6: 'st6' +} + + +async def async_setup_platform( + hass, config, async_add_devices, discovery_info=None): + """Set up the OpenUV binary sensor platform.""" + if discovery_info is None: + return + + openuv = hass.data[DOMAIN] + + sensors = [] + for sensor_type in discovery_info[CONF_MONITORED_CONDITIONS]: + name, icon, unit = SENSORS[sensor_type] + sensors.append(OpenUvSensor(openuv, sensor_type, name, icon, unit)) + + async_add_devices(sensors, True) + + +class OpenUvSensor(OpenUvEntity): + """Define a binary sensor for OpenUV.""" + + def __init__(self, openuv, sensor_type, name, icon, unit): + """Initialize the sensor.""" + super().__init__(openuv) + + self._icon = icon + self._latitude = openuv.client.latitude + self._longitude = openuv.client.longitude + self._name = name + self._sensor_type = sensor_type + self._state = None + self._unit = unit + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def should_poll(self): + """Disable polling.""" + return False + + @property + def state(self): + """Return the status of the sensor.""" + return self._state + + @property + def unique_id(self) -> str: + """Return a unique, HASS-friendly identifier for this entity.""" + return '{0}_{1}_{2}'.format( + self._latitude, self._longitude, self._sensor_type) + + @property + def unit_of_measurement(self): + """Return the unit the value is expressed in.""" + return self._unit + + @callback + def _update_data(self): + """Update the state.""" + self.async_schedule_update_ha_state(True) + + async def async_added_to_hass(self): + """Register callbacks.""" + async_dispatcher_connect(self.hass, TOPIC_UPDATE, self._update_data) + + async def async_update(self): + """Update the state.""" + data = self.openuv.data[DATA_UV]['result'] + if self._sensor_type == TYPE_CURRENT_OZONE_LEVEL: + self._state = data['ozone'] + elif self._sensor_type == TYPE_CURRENT_UV_INDEX: + self._state = data['uv'] + elif self._sensor_type == TYPE_MAX_UV_INDEX: + self._state = data['uv_max'] + self._attrs.update({ + ATTR_MAX_UV_TIME: as_local( + parse_datetime(data['uv_max_time'])) + }) + elif self._sensor_type in (TYPE_SAFE_EXPOSURE_TIME_1, + TYPE_SAFE_EXPOSURE_TIME_2, + TYPE_SAFE_EXPOSURE_TIME_3, + TYPE_SAFE_EXPOSURE_TIME_4, + TYPE_SAFE_EXPOSURE_TIME_5, + TYPE_SAFE_EXPOSURE_TIME_6): + self._state = data['safe_exposure_time'][EXPOSURE_TYPE_MAP[ + self._sensor_type]] diff --git a/requirements_all.txt b/requirements_all.txt index b0f2172834e..aa619142e01 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -962,6 +962,9 @@ pynut2==2.1.2 # homeassistant.components.binary_sensor.nx584 pynx584==0.4 +# homeassistant.components.openuv +pyopenuv==1.0.1 + # homeassistant.components.iota pyota==2.0.5 -- GitLab