From 8d2606134d3918d9210ccaf0a24cbbf3ef65c5a7 Mon Sep 17 00:00:00 2001 From: Nathan Tilley Date: Wed, 24 Feb 2021 15:11:20 -0500 Subject: [PATCH] Add FAA Delays Integration (#41347) Co-authored-by: Franck Nijhof Co-authored-by: Martin Hjelmare --- .coveragerc | 2 + CODEOWNERS | 1 + .../components/faa_delays/__init__.py | 84 ++++++++++++ .../components/faa_delays/binary_sensor.py | 93 ++++++++++++++ .../components/faa_delays/config_flow.py | 62 +++++++++ homeassistant/components/faa_delays/const.py | 28 ++++ .../components/faa_delays/manifest.json | 8 ++ .../components/faa_delays/strings.json | 21 +++ .../faa_delays/translations/en.json | 19 +++ homeassistant/generated/config_flows.py | 1 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/faa_delays/__init__.py | 1 + .../components/faa_delays/test_config_flow.py | 120 ++++++++++++++++++ 14 files changed, 446 insertions(+) create mode 100644 homeassistant/components/faa_delays/__init__.py create mode 100644 homeassistant/components/faa_delays/binary_sensor.py create mode 100644 homeassistant/components/faa_delays/config_flow.py create mode 100644 homeassistant/components/faa_delays/const.py create mode 100644 homeassistant/components/faa_delays/manifest.json create mode 100644 homeassistant/components/faa_delays/strings.json create mode 100644 homeassistant/components/faa_delays/translations/en.json create mode 100644 tests/components/faa_delays/__init__.py create mode 100644 tests/components/faa_delays/test_config_flow.py diff --git a/.coveragerc b/.coveragerc index dfa742b490c..50fcf151821 100644 --- a/.coveragerc +++ b/.coveragerc @@ -272,6 +272,8 @@ omit = homeassistant/components/evohome/* homeassistant/components/ezviz/* homeassistant/components/familyhub/camera.py + homeassistant/components/faa_delays/__init__.py + homeassistant/components/faa_delays/binary_sensor.py homeassistant/components/fastdotcom/* homeassistant/components/ffmpeg/camera.py homeassistant/components/fibaro/* diff --git a/CODEOWNERS b/CODEOWNERS index 398e5b15f7f..b0a31203009 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -146,6 +146,7 @@ homeassistant/components/esphome/* @OttoWinter homeassistant/components/essent/* @TheLastProject homeassistant/components/evohome/* @zxdavb homeassistant/components/ezviz/* @baqs +homeassistant/components/faa_delays/* @ntilley905 homeassistant/components/fastdotcom/* @rohankapoorcom homeassistant/components/file/* @fabaff homeassistant/components/filter/* @dgomes diff --git a/homeassistant/components/faa_delays/__init__.py b/homeassistant/components/faa_delays/__init__.py new file mode 100644 index 00000000000..b9def765123 --- /dev/null +++ b/homeassistant/components/faa_delays/__init__.py @@ -0,0 +1,84 @@ +"""The FAA Delays integration.""" +import asyncio +from datetime import timedelta +import logging + +from aiohttp import ClientConnectionError +from async_timeout import timeout +from faadelays import Airport + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_ID +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 DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = ["binary_sensor"] + + +async def async_setup(hass: HomeAssistant, config: dict): + """Set up the FAA Delays component.""" + hass.data[DOMAIN] = {} + return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry): + """Set up FAA Delays from a config entry.""" + code = entry.data[CONF_ID] + + coordinator = FAADataUpdateCoordinator(hass, code) + await coordinator.async_refresh() + + if not coordinator.last_update_success: + raise ConfigEntryNotReady + + hass.data[DOMAIN][entry.entry_id] = coordinator + + 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 FAADataUpdateCoordinator(DataUpdateCoordinator): + """Class to manage fetching FAA API data from a single endpoint.""" + + def __init__(self, hass, code): + """Initialize the coordinator.""" + super().__init__( + hass, _LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1) + ) + self.session = aiohttp_client.async_get_clientsession(hass) + self.data = Airport(code, self.session) + self.code = code + + async def _async_update_data(self): + try: + with timeout(10): + await self.data.update() + except ClientConnectionError as err: + raise UpdateFailed(err) from err + return self.data diff --git a/homeassistant/components/faa_delays/binary_sensor.py b/homeassistant/components/faa_delays/binary_sensor.py new file mode 100644 index 00000000000..6c5876b7017 --- /dev/null +++ b/homeassistant/components/faa_delays/binary_sensor.py @@ -0,0 +1,93 @@ +"""Platform for FAA Delays sensor component.""" +from homeassistant.components.binary_sensor import BinarySensorEntity +from homeassistant.const import ATTR_ICON, ATTR_NAME +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN, FAA_BINARY_SENSORS + + +async def async_setup_entry(hass, entry, async_add_entities): + """Set up a FAA sensor based on a config entry.""" + coordinator = hass.data[DOMAIN][entry.entry_id] + + binary_sensors = [] + for kind, attrs in FAA_BINARY_SENSORS.items(): + name = attrs[ATTR_NAME] + icon = attrs[ATTR_ICON] + + binary_sensors.append( + FAABinarySensor(coordinator, kind, name, icon, entry.entry_id) + ) + + async_add_entities(binary_sensors) + + +class FAABinarySensor(CoordinatorEntity, BinarySensorEntity): + """Define a binary sensor for FAA Delays.""" + + def __init__(self, coordinator, sensor_type, name, icon, entry_id): + """Initialize the sensor.""" + super().__init__(coordinator) + + self.coordinator = coordinator + self._entry_id = entry_id + self._icon = icon + self._name = name + self._sensor_type = sensor_type + self._id = self.coordinator.data.iata + self._attrs = {} + + @property + def name(self): + """Return the name of the sensor.""" + return f"{self._id} {self._name}" + + @property + def icon(self): + """Return the icon.""" + return self._icon + + @property + def is_on(self): + """Return the status of the sensor.""" + if self._sensor_type == "GROUND_DELAY": + return self.coordinator.data.ground_delay.status + if self._sensor_type == "GROUND_STOP": + return self.coordinator.data.ground_stop.status + if self._sensor_type == "DEPART_DELAY": + return self.coordinator.data.depart_delay.status + if self._sensor_type == "ARRIVE_DELAY": + return self.coordinator.data.arrive_delay.status + if self._sensor_type == "CLOSURE": + return self.coordinator.data.closure.status + return None + + @property + def unique_id(self): + """Return a unique, Home Assistant friendly identifier for this entity.""" + return f"{self._id}_{self._sensor_type}" + + @property + def device_state_attributes(self): + """Return attributes for sensor.""" + if self._sensor_type == "GROUND_DELAY": + self._attrs["average"] = self.coordinator.data.ground_delay.average + self._attrs["reason"] = self.coordinator.data.ground_delay.reason + elif self._sensor_type == "GROUND_STOP": + self._attrs["endtime"] = self.coordinator.data.ground_stop.endtime + self._attrs["reason"] = self.coordinator.data.ground_stop.reason + elif self._sensor_type == "DEPART_DELAY": + self._attrs["minimum"] = self.coordinator.data.depart_delay.minimum + self._attrs["maximum"] = self.coordinator.data.depart_delay.maximum + self._attrs["trend"] = self.coordinator.data.depart_delay.trend + self._attrs["reason"] = self.coordinator.data.depart_delay.reason + elif self._sensor_type == "ARRIVE_DELAY": + self._attrs["minimum"] = self.coordinator.data.arrive_delay.minimum + self._attrs["maximum"] = self.coordinator.data.arrive_delay.maximum + self._attrs["trend"] = self.coordinator.data.arrive_delay.trend + self._attrs["reason"] = self.coordinator.data.arrive_delay.reason + elif self._sensor_type == "CLOSURE": + self._attrs["begin"] = self.coordinator.data.closure.begin + self._attrs["end"] = self.coordinator.data.closure.end + self._attrs["reason"] = self.coordinator.data.closure.reason + return self._attrs diff --git a/homeassistant/components/faa_delays/config_flow.py b/homeassistant/components/faa_delays/config_flow.py new file mode 100644 index 00000000000..46d917cc92f --- /dev/null +++ b/homeassistant/components/faa_delays/config_flow.py @@ -0,0 +1,62 @@ +"""Config flow for FAA Delays integration.""" +import logging + +from aiohttp import ClientConnectionError +import faadelays +import voluptuous as vol + +from homeassistant import config_entries +from homeassistant.const import CONF_ID +from homeassistant.helpers import aiohttp_client + +from .const import DOMAIN # pylint:disable=unused-import + +_LOGGER = logging.getLogger(__name__) + +DATA_SCHEMA = vol.Schema({vol.Required(CONF_ID): str}) + + +class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): + """Handle a config flow for FAA Delays.""" + + VERSION = 1 + CONNECTION_CLASS = config_entries.CONN_CLASS_CLOUD_POLL + + async def async_step_user(self, user_input=None): + """Handle the initial step.""" + errors = {} + if user_input is not None: + + await self.async_set_unique_id(user_input[CONF_ID]) + self._abort_if_unique_id_configured() + + websession = aiohttp_client.async_get_clientsession(self.hass) + + data = faadelays.Airport(user_input[CONF_ID], websession) + + try: + await data.update() + + except faadelays.InvalidAirport: + _LOGGER.error("Airport code %s is invalid", user_input[CONF_ID]) + errors[CONF_ID] = "invalid_airport" + + except ClientConnectionError: + _LOGGER.error("Error connecting to FAA API") + errors["base"] = "cannot_connect" + + except Exception as error: # pylint: disable=broad-except + _LOGGER.exception("Unexpected exception: %s", error) + errors["base"] = "unknown" + + if not errors: + _LOGGER.debug( + "Creating entry with id: %s, name: %s", + user_input[CONF_ID], + data.name, + ) + return self.async_create_entry(title=data.name, data=user_input) + + return self.async_show_form( + step_id="user", data_schema=DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/faa_delays/const.py b/homeassistant/components/faa_delays/const.py new file mode 100644 index 00000000000..c725be88106 --- /dev/null +++ b/homeassistant/components/faa_delays/const.py @@ -0,0 +1,28 @@ +"""Constants for the FAA Delays integration.""" + +from homeassistant.const import ATTR_ICON, ATTR_NAME + +DOMAIN = "faa_delays" + +FAA_BINARY_SENSORS = { + "GROUND_DELAY": { + ATTR_NAME: "Ground Delay", + ATTR_ICON: "mdi:airport", + }, + "GROUND_STOP": { + ATTR_NAME: "Ground Stop", + ATTR_ICON: "mdi:airport", + }, + "DEPART_DELAY": { + ATTR_NAME: "Departure Delay", + ATTR_ICON: "mdi:airplane-takeoff", + }, + "ARRIVE_DELAY": { + ATTR_NAME: "Arrival Delay", + ATTR_ICON: "mdi:airplane-landing", + }, + "CLOSURE": { + ATTR_NAME: "Closure", + ATTR_ICON: "mdi:airplane:off", + }, +} diff --git a/homeassistant/components/faa_delays/manifest.json b/homeassistant/components/faa_delays/manifest.json new file mode 100644 index 00000000000..4148e7b956f --- /dev/null +++ b/homeassistant/components/faa_delays/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "faa_delays", + "name": "FAA Delays", + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/faadelays", + "requirements": ["faadelays==0.0.6"], + "codeowners": ["@ntilley905"] +} diff --git a/homeassistant/components/faa_delays/strings.json b/homeassistant/components/faa_delays/strings.json new file mode 100644 index 00000000000..92a9dafb4da --- /dev/null +++ b/homeassistant/components/faa_delays/strings.json @@ -0,0 +1,21 @@ +{ + "config": { + "step": { + "user": { + "title": "FAA Delays", + "description": "Enter a US Airport Code in IATA Format", + "data": { + "id": "Airport" + } + } + }, + "error": { + "invalid_airport": "Airport code is not valid", + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "This airport is already configured." + } + } +} diff --git a/homeassistant/components/faa_delays/translations/en.json b/homeassistant/components/faa_delays/translations/en.json new file mode 100644 index 00000000000..48e9e1c8993 --- /dev/null +++ b/homeassistant/components/faa_delays/translations/en.json @@ -0,0 +1,19 @@ +{ + "config": { + "abort": { + "already_configured": "This airport is already configured." + }, + "error": { + "invalid_airport": "Airport code is not valid" + }, + "step": { + "user": { + "title": "FAA Delays", + "description": "Enter a US Airport Code in IATA Format", + "data": { + "id": "Airport" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index da16d32d45b..c3d629ebe29 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -64,6 +64,7 @@ FLOWS = [ "enocean", "epson", "esphome", + "faa_delays", "fireservicerota", "flick_electric", "flo", diff --git a/requirements_all.txt b/requirements_all.txt index bcef556ae38..21bcd4d604a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -574,6 +574,9 @@ eternalegypt==0.0.12 # homeassistant.components.evohome evohome-async==0.3.5.post1 +# homeassistant.components.faa_delays +faadelays==0.0.6 + # homeassistant.components.dlib_face_detect # homeassistant.components.dlib_face_identify # face_recognition==1.2.3 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 155aaa269e5..ae06051a774 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -302,6 +302,9 @@ ephem==3.7.7.0 # homeassistant.components.epson epson-projector==0.2.3 +# homeassistant.components.faa_delays +faadelays==0.0.6 + # homeassistant.components.feedreader feedparser==6.0.2 diff --git a/tests/components/faa_delays/__init__.py b/tests/components/faa_delays/__init__.py new file mode 100644 index 00000000000..2bb5194605d --- /dev/null +++ b/tests/components/faa_delays/__init__.py @@ -0,0 +1 @@ +"""Tests for the FAA Delays integration.""" diff --git a/tests/components/faa_delays/test_config_flow.py b/tests/components/faa_delays/test_config_flow.py new file mode 100644 index 00000000000..c289f154415 --- /dev/null +++ b/tests/components/faa_delays/test_config_flow.py @@ -0,0 +1,120 @@ +"""Test the FAA Delays config flow.""" +from unittest.mock import patch + +from aiohttp import ClientConnectionError +import faadelays + +from homeassistant import config_entries, data_entry_flow, setup +from homeassistant.components.faa_delays.const import DOMAIN +from homeassistant.const import CONF_ID +from homeassistant.exceptions import HomeAssistantError + +from tests.common import MockConfigEntry + + +async def mock_valid_airport(self, *args, **kwargs): + """Return a valid airport.""" + self.name = "Test airport" + + +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.object(faadelays.Airport, "update", new=mock_valid_airport), patch( + "homeassistant.components.faa_delays.async_setup", return_value=True + ) as mock_setup, patch( + "homeassistant.components.faa_delays.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "id": "test", + }, + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "Test airport" + assert result2["data"] == { + "id": "test", + } + await hass.async_block_till_done() + assert len(mock_setup.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_error(hass): + """Test that we handle a duplicate configuration.""" + conf = {CONF_ID: "test"} + + MockConfigEntry(domain=DOMAIN, unique_id="test", data=conf).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER}, data=conf + ) + + assert result["type"] == data_entry_flow.RESULT_TYPE_ABORT + assert result["reason"] == "already_configured" + + +async def test_form_invalid_airport(hass): + """Test we handle invalid airport.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch( + "faadelays.Airport.update", + side_effect=faadelays.InvalidAirport, + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "id": "test", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {CONF_ID: "invalid_airport"} + + +async def test_form_cannot_connect(hass): + """Test we handle a connection error.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("faadelays.Airport.update", side_effect=ClientConnectionError): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "id": "test", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "cannot_connect"} + + +async def test_form_unexpected_exception(hass): + """Test we handle an unexpected exception.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + + with patch("faadelays.Airport.update", side_effect=HomeAssistantError): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "id": "test", + }, + ) + + assert result2["type"] == "form" + assert result2["errors"] == {"base": "unknown"}