diff --git a/.coveragerc b/.coveragerc index 507c216d0fe..671753666a6 100644 --- a/.coveragerc +++ b/.coveragerc @@ -576,6 +576,7 @@ omit = homeassistant/components/lametric/* homeassistant/components/lannouncer/notify.py homeassistant/components/lastfm/sensor.py + homeassistant/components/launch_library/__init__.py homeassistant/components/launch_library/const.py homeassistant/components/launch_library/sensor.py homeassistant/components/lcn/binary_sensor.py diff --git a/CODEOWNERS b/CODEOWNERS index 999430d0f3b..10100d1c21c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -489,7 +489,8 @@ tests/components/kraken/* @eifinger homeassistant/components/kulersky/* @emlove tests/components/kulersky/* @emlove homeassistant/components/lametric/* @robbiet480 -homeassistant/components/launch_library/* @ludeeus +homeassistant/components/launch_library/* @ludeeus @DurgNomis-drol +tests/components/launch_library/* @ludeeus @DurgNomis-drol homeassistant/components/lcn/* @alengwenus tests/components/lcn/* @alengwenus homeassistant/components/lg_netcast/* @Drafteed diff --git a/homeassistant/components/launch_library/__init__.py b/homeassistant/components/launch_library/__init__.py index ba4b78ab31f..db4512b0885 100644 --- a/homeassistant/components/launch_library/__init__.py +++ b/homeassistant/components/launch_library/__init__.py @@ -1 +1,55 @@ """The launch_library component.""" +from datetime import timedelta +import logging + +from pylaunches import PyLaunches, PyLaunchesException + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) + +PLATFORMS = [Platform.SENSOR] + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up this integration using UI.""" + + hass.data.setdefault(DOMAIN, {}) + + session = async_get_clientsession(hass) + launches = PyLaunches(session) + + async def async_update(): + try: + return await launches.upcoming_launches() + except PyLaunchesException as ex: + raise UpdateFailed(ex) from ex + + coordinator = DataUpdateCoordinator( + hass, + _LOGGER, + name=DOMAIN, + update_method=async_update, + update_interval=timedelta(hours=1), + ) + + await coordinator.async_config_entry_first_refresh() + + hass.data[DOMAIN] = coordinator + + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Handle removal of an entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS): + del hass.data[DOMAIN] + return unload_ok diff --git a/homeassistant/components/launch_library/config_flow.py b/homeassistant/components/launch_library/config_flow.py new file mode 100644 index 00000000000..2cc668c8e35 --- /dev/null +++ b/homeassistant/components/launch_library/config_flow.py @@ -0,0 +1,27 @@ +"""Config flow to configure launch library component.""" +from homeassistant import config_entries +from homeassistant.const import CONF_NAME +from homeassistant.data_entry_flow import FlowResult + +from .const import DOMAIN + + +class LaunchLibraryFlowHandler(config_entries.ConfigFlow, domain=DOMAIN): + """Config flow for Launch Library component.""" + + VERSION = 1 + + async def async_step_user(self, user_input=None) -> FlowResult: + """Handle a flow initialized by the user.""" + # Check if already configured + if self._async_current_entries(): + return self.async_abort(reason="single_instance_allowed") + + if user_input is not None: + return self.async_create_entry(title="Launch Library", data=user_input) + + return self.async_show_form(step_id="user") + + async def async_step_import(self, conf: dict) -> FlowResult: + """Import a configuration from config.yaml.""" + return await self.async_step_user(user_input={CONF_NAME: conf[CONF_NAME]}) diff --git a/homeassistant/components/launch_library/const.py b/homeassistant/components/launch_library/const.py index 0d6e4f22f76..b0924bfc107 100644 --- a/homeassistant/components/launch_library/const.py +++ b/homeassistant/components/launch_library/const.py @@ -1,5 +1,7 @@ """Constants for launch_library.""" +DOMAIN = "launch_library" + ATTR_AGENCY = "agency" ATTR_AGENCY_COUNTRY_CODE = "agency_country_code" ATTR_LAUNCH_TIME = "launch_time" diff --git a/homeassistant/components/launch_library/manifest.json b/homeassistant/components/launch_library/manifest.json index fee5aeea0fe..8d3a6fa2814 100644 --- a/homeassistant/components/launch_library/manifest.json +++ b/homeassistant/components/launch_library/manifest.json @@ -1,8 +1,9 @@ { "domain": "launch_library", "name": "Launch Library", + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/launch_library", "requirements": ["pylaunches==1.2.0"], - "codeowners": ["@ludeeus"], + "codeowners": ["@ludeeus", "@DurgNomis-drol"], "iot_class": "cloud_polling" } diff --git a/homeassistant/components/launch_library/sensor.py b/homeassistant/components/launch_library/sensor.py index 205c815ef42..b93c33daa1c 100644 --- a/homeassistant/components/launch_library/sensor.py +++ b/homeassistant/components/launch_library/sensor.py @@ -1,19 +1,23 @@ -"""A sensor platform that give you information about the next space launch.""" +"""Support for Launch Library sensors.""" from __future__ import annotations -from datetime import timedelta import logging +from typing import Any -from pylaunches import PyLaunches, PyLaunchesException +from pylaunches.objects.launch import Launch import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ATTR_ATTRIBUTION, CONF_NAME -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType +from homeassistant.helpers.update_coordinator import ( + CoordinatorEntity, + DataUpdateCoordinator, +) from .const import ( ATTR_AGENCY, @@ -22,11 +26,11 @@ from .const import ( ATTR_STREAM, ATTRIBUTION, DEFAULT_NAME, + DOMAIN, ) _LOGGER = logging.getLogger(__name__) -SCAN_INTERVAL = timedelta(hours=1) PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( {vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string} @@ -39,39 +43,86 @@ async def async_setup_platform( async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Create the launch sensor.""" - name = config[CONF_NAME] - session = async_get_clientsession(hass) - launches = PyLaunches(session) - - async_add_entities([LaunchLibrarySensor(launches, name)], True) + """Import Launch Library configuration from yaml.""" + _LOGGER.warning( + "Configuration of the launch_library platform in YAML is deprecated and will be " + "removed in Home Assistant 2022.4; Your existing configuration " + "has been imported into the UI automatically and can be safely removed " + "from your configuration.yaml file" + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) -class LaunchLibrarySensor(SensorEntity): - """Representation of a launch_library Sensor.""" +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the sensor platform.""" - _attr_icon = "mdi:rocket" + name = entry.data.get(CONF_NAME, DEFAULT_NAME) - def __init__(self, api: PyLaunches, name: str) -> None: - """Initialize the sensor.""" - self.api = api + coordinator = hass.data[DOMAIN] + + async_add_entities( + [ + NextLaunchSensor(coordinator, entry.entry_id, name), + ] + ) + + +class NextLaunchSensor(CoordinatorEntity, SensorEntity): + """Representation of the next launch sensor.""" + + _attr_attribution = ATTRIBUTION + _attr_icon = "mdi:rocket-launch" + _next_launch: Launch | None = None + + def __init__( + self, coordinator: DataUpdateCoordinator, entry_id: str, name: str + ) -> None: + """Initialize a Launch Library entity.""" + super().__init__(coordinator) self._attr_name = name + self._attr_unique_id = f"{entry_id}_next_launch" - async def async_update(self) -> None: - """Get the latest data.""" - try: - launches = await self.api.upcoming_launches() - except PyLaunchesException as exception: - _LOGGER.error("Error getting data, %s", exception) - self._attr_available = False - else: - if next_launch := next((launch for launch in launches), None): - self._attr_available = True - self._attr_native_value = next_launch.name - self._attr_extra_state_attributes = { - ATTR_LAUNCH_TIME: next_launch.net, - ATTR_AGENCY: next_launch.launch_service_provider.name, - ATTR_AGENCY_COUNTRY_CODE: next_launch.pad.location.country_code, - ATTR_STREAM: next_launch.webcast_live, - ATTR_ATTRIBUTION: ATTRIBUTION, - } + @property + def native_value(self) -> str | None: + """Return the state of the sensor.""" + if self._next_launch is None: + return None + return self._next_launch.name + + @property + def available(self) -> bool: + """Return if the sensor is available.""" + return super().available and self._next_launch is not None + + @property + def extra_state_attributes(self) -> dict[str, Any] | None: + """Return the attributes of the sensor.""" + if self._next_launch is None: + return None + return { + ATTR_LAUNCH_TIME: self._next_launch.net, + ATTR_AGENCY: self._next_launch.launch_service_provider.name, + ATTR_AGENCY_COUNTRY_CODE: self._next_launch.pad.location.country_code, + ATTR_STREAM: self._next_launch.webcast_live, + } + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + self._next_launch = next((launch for launch in self.coordinator.data), None) + super()._handle_coordinator_update() + + async def async_added_to_hass(self) -> None: + """When entity is added to hass.""" + await super().async_added_to_hass() + self._handle_coordinator_update() diff --git a/homeassistant/components/launch_library/strings.json b/homeassistant/components/launch_library/strings.json new file mode 100644 index 00000000000..678fc7c6f4f --- /dev/null +++ b/homeassistant/components/launch_library/strings.json @@ -0,0 +1,12 @@ +{ + "config": { + "step": { + "user": { + "description": "Do you want to configure the Launch Library?" + } + }, + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + } + } +} \ No newline at end of file diff --git a/homeassistant/components/launch_library/translations/en.json b/homeassistant/components/launch_library/translations/en.json new file mode 100644 index 00000000000..2baddafbeed --- /dev/null +++ b/homeassistant/components/launch_library/translations/en.json @@ -0,0 +1,12 @@ +{ + "config": { + "abort": { + "single_instance_allowed": "Already configured. Only a single configuration possible." + }, + "step": { + "user": { + "description": "Do you want to configure the Launch Library?" + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 49166a5cb73..7e3a66a073a 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -166,6 +166,7 @@ FLOWS = [ "kostal_plenticore", "kraken", "kulersky", + "launch_library", "life360", "lifx", "litejet", diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 6f104944679..5ed608d86b7 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1004,6 +1004,9 @@ pykulersky==0.5.2 # homeassistant.components.lastfm pylast==4.2.1 +# homeassistant.components.launch_library +pylaunches==1.2.0 + # homeassistant.components.forked_daapd pylibrespot-java==0.1.0 diff --git a/tests/components/launch_library/__init__.py b/tests/components/launch_library/__init__.py new file mode 100644 index 00000000000..f6264de1914 --- /dev/null +++ b/tests/components/launch_library/__init__.py @@ -0,0 +1 @@ +"""Tests for the launch_library component.""" diff --git a/tests/components/launch_library/test_config_flow.py b/tests/components/launch_library/test_config_flow.py new file mode 100644 index 00000000000..f60ee76d0f7 --- /dev/null +++ b/tests/components/launch_library/test_config_flow.py @@ -0,0 +1,64 @@ +"""Test launch_library config flow.""" +from unittest.mock import patch + +from homeassistant import data_entry_flow +from homeassistant.components.launch_library.const import DEFAULT_NAME, DOMAIN +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_NAME + +from tests.common import MockConfigEntry + + +async def test_import(hass): + """Test entry will be imported.""" + + imported_config = {CONF_NAME: DEFAULT_NAME} + + with patch( + "homeassistant.components.launch_library.async_setup_entry", return_value=True + ): + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=imported_config + ) + assert result.get("type") == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result.get("result").data == imported_config + + +async def test_create_entry(hass): + """Test we can finish a config flow.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == data_entry_flow.RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + + with patch( + "homeassistant.components.launch_library.async_setup_entry", return_value=True + ): + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + + assert result.get("type") == data_entry_flow.RESULT_TYPE_CREATE_ENTRY + assert result.get("result").data == {} + + +async def test_integration_already_exists(hass): + """Test we only allow a single config flow.""" + + MockConfigEntry( + domain=DOMAIN, + data={}, + ).add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data={} + ) + + assert result.get("type") == data_entry_flow.RESULT_TYPE_ABORT + assert result.get("reason") == "single_instance_allowed"