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