Fix Aurora integration including externalizing API to PyPi and adding config_flow (#43045)

Co-authored-by: Pawel <pszafer@gmail.com>
pull/43114/head
djtimca 2020-11-11 14:36:16 -05:00 committed by GitHub
parent df5a8c4dac
commit d47b3a5f44
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 477 additions and 178 deletions

View File

@ -65,6 +65,9 @@ omit =
homeassistant/components/asterisk_mbox/*
homeassistant/components/aten_pe/*
homeassistant/components/atome/*
homeassistant/components/aurora/__init__.py
homeassistant/components/aurora/binary_sensor.py
homeassistant/components/aurora/const.py
homeassistant/components/aurora_abb_powerone/sensor.py
homeassistant/components/avea/light.py
homeassistant/components/avion/light.py

View File

@ -48,6 +48,7 @@ homeassistant/components/atag/* @MatsNL
homeassistant/components/aten_pe/* @mtdcr
homeassistant/components/atome/* @baqs
homeassistant/components/august/* @bdraco
homeassistant/components/aurora/* @djtimca
homeassistant/components/aurora_abb_powerone/* @davet2001
homeassistant/components/auth/* @home-assistant/core
homeassistant/components/automation/* @home-assistant/core

View File

@ -1 +1,130 @@
"""The aurora component."""
import asyncio
from datetime import timedelta
import logging
from auroranoaa import AuroraForecast
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryNotReady
from homeassistant.helpers import aiohttp_client
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import (
AURORA_API,
CONF_THRESHOLD,
COORDINATOR,
DEFAULT_POLLING_INTERVAL,
DEFAULT_THRESHOLD,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
PLATFORMS = ["binary_sensor"]
async def async_setup(hass: HomeAssistant, config: dict):
"""Set up the Aurora component."""
hass.data.setdefault(DOMAIN, {})
return True
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Set up Aurora from a config entry."""
conf = entry.data
options = entry.options
session = aiohttp_client.async_get_clientsession(hass)
api = AuroraForecast(session)
longitude = conf[CONF_LONGITUDE]
latitude = conf[CONF_LATITUDE]
polling_interval = DEFAULT_POLLING_INTERVAL
threshold = options.get(CONF_THRESHOLD, DEFAULT_THRESHOLD)
name = conf[CONF_NAME]
coordinator = AuroraDataUpdateCoordinator(
hass=hass,
name=name,
polling_interval=polling_interval,
api=api,
latitude=latitude,
longitude=longitude,
threshold=threshold,
)
await coordinator.async_refresh()
if not coordinator.last_update_success:
raise ConfigEntryNotReady
hass.data[DOMAIN][entry.entry_id] = {
COORDINATOR: coordinator,
AURORA_API: api,
}
for component in PLATFORMS:
hass.async_create_task(
hass.config_entries.async_forward_entry_setup(entry, component)
)
return True
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry):
"""Unload a config entry."""
unload_ok = all(
await asyncio.gather(
*[
hass.config_entries.async_forward_entry_unload(entry, component)
for component in PLATFORMS
]
)
)
if unload_ok:
hass.data[DOMAIN].pop(entry.entry_id)
return unload_ok
class AuroraDataUpdateCoordinator(DataUpdateCoordinator):
"""Class to manage fetching data from the NOAA Aurora API."""
def __init__(
self,
hass: HomeAssistant,
name: str,
polling_interval: int,
api: str,
latitude: float,
longitude: float,
threshold: float,
):
"""Initialize the data updater."""
super().__init__(
hass=hass,
logger=_LOGGER,
name=name,
update_interval=timedelta(minutes=polling_interval),
)
self.api = api
self.name = name
self.latitude = int(latitude)
self.longitude = int(longitude)
self.threshold = int(threshold)
async def _async_update_data(self):
"""Fetch the data from the NOAA Aurora Forecast."""
try:
return await self.api.get_forecast_data(self.longitude, self.latitude)
except ConnectionError as error:
raise UpdateFailed(f"Error updating from NOAA: {error}") from error

View File

@ -1,146 +1,75 @@
"""Support for aurora forecast data sensor."""
from datetime import timedelta
import logging
from math import floor
from aiohttp.hdrs import USER_AGENT
import requests
import voluptuous as vol
from homeassistant.components.binary_sensor import BinarySensorEntity
from homeassistant.const import ATTR_NAME
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity
from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME
import homeassistant.helpers.config_validation as cv
from homeassistant.util import Throttle
from . import AuroraDataUpdateCoordinator
from .const import (
ATTR_IDENTIFIERS,
ATTR_MANUFACTURER,
ATTR_MODEL,
ATTRIBUTION,
COORDINATOR,
DOMAIN,
)
_LOGGER = logging.getLogger(__name__)
ATTRIBUTION = "Data provided by the National Oceanic and Atmospheric Administration"
CONF_THRESHOLD = "forecast_threshold"
DEFAULT_DEVICE_CLASS = "visible"
DEFAULT_NAME = "Aurora Visibility"
DEFAULT_THRESHOLD = 75
async def async_setup_entry(hass, entry, async_add_entries):
"""Set up the binary_sensor platform."""
coordinator = hass.data[DOMAIN][entry.entry_id][COORDINATOR]
name = coordinator.name
HA_USER_AGENT = "Home Assistant Aurora Tracker v.0.1.0"
entity = AuroraSensor(coordinator, name)
MIN_TIME_BETWEEN_UPDATES = timedelta(minutes=5)
URL = "http://services.swpc.noaa.gov/text/aurora-nowcast-map.txt"
PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend(
{
vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string,
vol.Optional(CONF_THRESHOLD, default=DEFAULT_THRESHOLD): cv.positive_int,
}
)
async_add_entries([entity])
def setup_platform(hass, config, add_entities, discovery_info=None):
"""Set up the aurora sensor."""
if None in (hass.config.latitude, hass.config.longitude):
_LOGGER.error("Lat. or long. not set in Home Assistant config")
return False
name = config[CONF_NAME]
threshold = config[CONF_THRESHOLD]
try:
aurora_data = AuroraData(hass.config.latitude, hass.config.longitude, threshold)
aurora_data.update()
except requests.exceptions.HTTPError as error:
_LOGGER.error("Connection to aurora forecast service failed: %s", error)
return False
add_entities([AuroraSensor(aurora_data, name)], True)
class AuroraSensor(BinarySensorEntity):
class AuroraSensor(CoordinatorEntity, BinarySensorEntity):
"""Implementation of an aurora sensor."""
def __init__(self, aurora_data, name):
"""Initialize the sensor."""
self.aurora_data = aurora_data
def __init__(self, coordinator: AuroraDataUpdateCoordinator, name):
"""Define the binary sensor for the Aurora integration."""
super().__init__(coordinator=coordinator)
self._name = name
self.coordinator = coordinator
self._unique_id = f"{self.coordinator.latitude}_{self.coordinator.longitude}"
@property
def unique_id(self):
"""Define the unique id based on the latitude and longitude."""
return self._unique_id
@property
def name(self):
"""Return the name of the sensor."""
return f"{self._name}"
return self._name
@property
def is_on(self):
"""Return true if aurora is visible."""
return self.aurora_data.is_visible if self.aurora_data else False
@property
def device_class(self):
"""Return the class of this device."""
return DEFAULT_DEVICE_CLASS
return self.coordinator.data > self.coordinator.threshold
@property
def device_state_attributes(self):
"""Return the state attributes."""
attrs = {}
return {"attribution": ATTRIBUTION}
if self.aurora_data:
attrs["visibility_level"] = self.aurora_data.visibility_level
attrs["message"] = self.aurora_data.is_visible_text
attrs[ATTR_ATTRIBUTION] = ATTRIBUTION
return attrs
@property
def icon(self):
"""Return the icon for the sensor."""
return "mdi:hazard-lights"
def update(self):
"""Get the latest data from Aurora API and updates the states."""
self.aurora_data.update()
class AuroraData:
"""Get aurora forecast."""
def __init__(self, latitude, longitude, threshold):
"""Initialize the data object."""
self.latitude = latitude
self.longitude = longitude
self.headers = {USER_AGENT: HA_USER_AGENT}
self.threshold = int(threshold)
self.is_visible = None
self.is_visible_text = None
self.visibility_level = None
@Throttle(MIN_TIME_BETWEEN_UPDATES)
def update(self):
"""Get the latest data from the Aurora service."""
try:
self.visibility_level = self.get_aurora_forecast()
if int(self.visibility_level) > self.threshold:
self.is_visible = True
self.is_visible_text = "visible!"
else:
self.is_visible = False
self.is_visible_text = "nothing's out"
except requests.exceptions.HTTPError as error:
_LOGGER.error("Connection to aurora forecast service failed: %s", error)
return False
def get_aurora_forecast(self):
"""Get forecast data and parse for given long/lat."""
raw_data = requests.get(URL, headers=self.headers, timeout=5).text
# We discard comment rows (#)
# We split the raw text by line (\n)
# For each line we trim leading spaces and split by spaces
forecast_table = [
row.strip().split()
for row in raw_data.split("\n")
if not row.startswith("#")
]
# Convert lat and long for data points in table
# Assumes self.latitude belongs to [-90;90[ (South to North)
# Assumes self.longitude belongs to [-180;180[ (West to East)
# No assumptions made regarding the number of rows and columns
converted_latitude = floor((self.latitude + 90) * len(forecast_table) / 180)
converted_longitude = floor(
(self.longitude + 180) * len(forecast_table[converted_latitude]) / 360
)
return forecast_table[converted_latitude][converted_longitude]
@property
def device_info(self):
"""Define the device based on name."""
return {
ATTR_IDENTIFIERS: {(DOMAIN, self._unique_id)},
ATTR_NAME: self.coordinator.name,
ATTR_MANUFACTURER: "NOAA",
ATTR_MODEL: "Aurora Visibility Sensor",
}

View File

@ -0,0 +1,110 @@
"""Config flow for SpaceX Launches and Starman."""
import logging
from auroranoaa import AuroraForecast
import voluptuous as vol
from homeassistant import config_entries
from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME
from homeassistant.core import callback
from homeassistant.helpers import aiohttp_client
from .const import CONF_THRESHOLD, DEFAULT_NAME, DEFAULT_THRESHOLD, DOMAIN
_LOGGER = logging.getLogger(__name__)
class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
"""Handle a config flow for NOAA Aurora Integration."""
VERSION = 1
CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL
@staticmethod
@callback
def async_get_options_flow(config_entry):
"""Get the options flow for this handler."""
return OptionsFlowHandler(config_entry)
async def async_step_user(self, user_input=None):
"""Handle the initial step."""
errors = {}
if user_input is not None:
name = user_input[CONF_NAME]
longitude = user_input[CONF_LONGITUDE]
latitude = user_input[CONF_LATITUDE]
session = aiohttp_client.async_get_clientsession(self.hass)
api = AuroraForecast(session=session)
try:
await api.get_forecast_data(longitude, latitude)
except ConnectionError:
errors["base"] = "cannot_connect"
except Exception: # pylint: disable=broad-except
_LOGGER.exception("Unexpected exception")
errors["base"] = "unknown"
else:
await self.async_set_unique_id(
f"{DOMAIN}_{user_input[CONF_LONGITUDE]}_{user_input[CONF_LATITUDE]}"
)
self._abort_if_unique_id_configured()
return self.async_create_entry(
title=f"Aurora - {name}", data=user_input
)
return self.async_show_form(
step_id="user",
data_schema=vol.Schema(
{
vol.Required(CONF_NAME, default=DEFAULT_NAME): str,
vol.Required(
CONF_LONGITUDE,
default=self.hass.config.longitude,
): vol.All(
vol.Coerce(float),
vol.Range(min=-180, max=180),
),
vol.Required(
CONF_LATITUDE,
default=self.hass.config.latitude,
): vol.All(
vol.Coerce(float),
vol.Range(min=-90, max=90),
),
}
),
errors=errors,
)
class OptionsFlowHandler(config_entries.OptionsFlow):
"""Handle options flow changes."""
def __init__(self, config_entry):
"""Initialize options flow."""
self.config_entry = config_entry
async def async_step_init(self, user_input=None):
"""Manage options."""
if user_input is not None:
return self.async_create_entry(title="", data=user_input)
return self.async_show_form(
step_id="init",
data_schema=vol.Schema(
{
vol.Required(
CONF_THRESHOLD,
default=self.config_entry.options.get(
CONF_THRESHOLD, DEFAULT_THRESHOLD
),
): vol.All(
vol.Coerce(int),
vol.Range(min=0, max=100),
),
}
),
)

View File

@ -0,0 +1,13 @@
"""Constants for the Aurora integration."""
DOMAIN = "aurora"
COORDINATOR = "coordinator"
AURORA_API = "aurora_api"
ATTR_IDENTIFIERS = "identifiers"
ATTR_MANUFACTURER = "manufacturer"
ATTR_MODEL = "model"
DEFAULT_POLLING_INTERVAL = 5
CONF_THRESHOLD = "forecast_threshold"
DEFAULT_THRESHOLD = 75
ATTRIBUTION = "Data provided by the National Oceanic and Atmospheric Administration"
DEFAULT_NAME = "Aurora Visibility"

View File

@ -2,5 +2,7 @@
"domain": "aurora",
"name": "Aurora",
"documentation": "https://www.home-assistant.io/integrations/aurora",
"codeowners": []
"config_flow": true,
"codeowners": ["@djtimca"],
"requirements": ["auroranoaa==0.0.1"]
}

View File

@ -0,0 +1,26 @@
{
"title": "NOAA Aurora Sensor",
"config": {
"step": {
"user": {
"data": {
"name": "[%key:common::config_flow::data::name%]",
"longitude": "[%key:common::config_flow::data::longitude%]",
"latitude": "[%key:common::config_flow::data::latitude%]"
}
}
},
"error": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
}
},
"options": {
"step": {
"init": {
"data": {
"threshold": "Threshold (%)"
}
}
}
}
}

View File

@ -0,0 +1,26 @@
{
"config": {
"error": {
"cannot_connect": "Failed to connect"
},
"step": {
"user": {
"data": {
"latitude": "Latitude",
"longitude": "Longitude",
"name": "Name"
}
}
}
},
"options": {
"step": {
"init": {
"data": {
"threshold": "Threshold (%)"
}
}
}
},
"title": "NOAA Aurora Sensor"
}

View File

@ -21,6 +21,7 @@ FLOWS = [
"arcam_fmj",
"atag",
"august",
"aurora",
"avri",
"awair",
"axis",

View File

@ -296,6 +296,9 @@ asyncpysupla==0.0.5
# homeassistant.components.aten_pe
atenpdu==0.3.0
# homeassistant.components.aurora
auroranoaa==0.0.1
# homeassistant.components.aurora_abb_powerone
aurorapy==0.2.6

View File

@ -173,6 +173,9 @@ arcam-fmj==0.5.3
# homeassistant.components.upnp
async-upnp-client==0.14.13
# homeassistant.components.aurora
auroranoaa==0.0.1
# homeassistant.components.stream
av==8.0.2

View File

@ -1,60 +0,0 @@
"""The tests for the Aurora sensor platform."""
import re
from homeassistant.components.aurora import binary_sensor as aurora
from tests.common import load_fixture
def test_setup_and_initial_state(hass, requests_mock):
"""Test that the component is created and initialized as expected."""
uri = re.compile(r"http://services\.swpc\.noaa\.gov/text/aurora-nowcast-map\.txt")
requests_mock.get(uri, text=load_fixture("aurora.txt"))
entities = []
def mock_add_entities(new_entities, update_before_add=False):
"""Mock add entities."""
if update_before_add:
for entity in new_entities:
entity.update()
for entity in new_entities:
entities.append(entity)
config = {"name": "Test", "forecast_threshold": 75}
aurora.setup_platform(hass, config, mock_add_entities)
aurora_component = entities[0]
assert len(entities) == 1
assert aurora_component.name == "Test"
assert aurora_component.device_state_attributes["visibility_level"] == "0"
assert aurora_component.device_state_attributes["message"] == "nothing's out"
assert not aurora_component.is_on
def test_custom_threshold_works(hass, requests_mock):
"""Test that the config can take a custom forecast threshold."""
uri = re.compile(r"http://services\.swpc\.noaa\.gov/text/aurora-nowcast-map\.txt")
requests_mock.get(uri, text=load_fixture("aurora.txt"))
entities = []
def mock_add_entities(new_entities, update_before_add=False):
"""Mock add entities."""
if update_before_add:
for entity in new_entities:
entity.update()
for entity in new_entities:
entities.append(entity)
config = {"name": "Test", "forecast_threshold": 1}
hass.config.longitude = 18.987
hass.config.latitude = 69.648
aurora.setup_platform(hass, config, mock_add_entities)
aurora_component = entities[0]
assert aurora_component.aurora_data.visibility_level == "16"
assert aurora_component.is_on

View File

@ -0,0 +1,113 @@
"""Test the Aurora config flow."""
from homeassistant import config_entries, data_entry_flow, setup
from homeassistant.components.aurora.const import DOMAIN
from tests.async_mock import patch
from tests.common import MockConfigEntry
DATA = {
"name": "Home",
"latitude": -10,
"longitude": 10.2,
}
async def test_form(hass):
"""Test we get the form."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
assert result["type"] == "form"
assert result["errors"] == {}
with patch(
"homeassistant.components.aurora.config_flow.AuroraForecast.get_forecast_data",
return_value=True,
), patch(
"homeassistant.components.aurora.async_setup", return_value=True
) as mock_setup, patch(
"homeassistant.components.aurora.async_setup_entry",
return_value=True,
) as mock_setup_entry:
result2 = await hass.config_entries.flow.async_configure(
result["flow_id"],
DATA,
)
assert result2["type"] == "create_entry"
assert result2["title"] == "Aurora - Home"
assert result2["data"] == DATA
await hass.async_block_till_done()
assert len(mock_setup.mock_calls) == 1
assert len(mock_setup_entry.mock_calls) == 1
async def test_form_cannot_connect(hass):
"""Test if invalid response or no connection returned from the API."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.aurora.AuroraForecast.get_forecast_data",
side_effect=ConnectionError,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
DATA,
)
assert result["type"] == "form"
assert result["step_id"] == "user"
assert result["errors"] == {"base": "cannot_connect"}
async def test_with_unknown_error(hass):
"""Test with unknown error response from the API."""
await setup.async_setup_component(hass, "persistent_notification", {})
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
)
with patch(
"homeassistant.components.aurora.AuroraForecast.get_forecast_data",
side_effect=Exception,
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
DATA,
)
assert result["type"] == "form"
assert result["step_id"] == "user"
assert result["errors"] == {"base": "unknown"}
async def test_option_flow(hass):
"""Test option flow."""
entry = MockConfigEntry(domain=DOMAIN, data=DATA)
entry.add_to_hass(hass)
assert not entry.options
with patch("homeassistant.components.aurora.async_setup_entry", return_value=True):
result = await hass.config_entries.options.async_init(
entry.entry_id,
data=None,
)
assert result["type"] == data_entry_flow.RESULT_TYPE_FORM
assert result["step_id"] == "init"
result = await hass.config_entries.options.async_configure(
result["flow_id"],
user_input={"forecast_threshold": 65},
)
assert result["type"] == data_entry_flow.RESULT_TYPE_CREATE_ENTRY
assert result["title"] == ""
assert result["data"]["forecast_threshold"] == 65