diff --git a/CODEOWNERS b/CODEOWNERS index f09785a778160c..8a72fadfbd9c40 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -894,6 +894,7 @@ build.json @home-assistant/supervisor /homeassistant/components/openhome/ @bazwilliams /tests/components/openhome/ @bazwilliams /homeassistant/components/opensky/ @joostlek +/tests/components/opensky/ @joostlek /homeassistant/components/opentherm_gw/ @mvn23 /tests/components/opentherm_gw/ @mvn23 /homeassistant/components/openuv/ @bachya diff --git a/homeassistant/components/opensky/__init__.py b/homeassistant/components/opensky/__init__.py index da805999d538aa..197356b2092cf8 100644 --- a/homeassistant/components/opensky/__init__.py +++ b/homeassistant/components/opensky/__init__.py @@ -1 +1,27 @@ """The opensky component.""" +from __future__ import annotations + +from python_opensky import OpenSky + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import CLIENT, DOMAIN, PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up opensky from a config entry.""" + + client = OpenSky(session=async_get_clientsession(hass)) + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = {CLIENT: client} + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload opensky config entry.""" + + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/opensky/config_flow.py b/homeassistant/components/opensky/config_flow.py new file mode 100644 index 00000000000000..6e3ffb5e2b17bd --- /dev/null +++ b/homeassistant/components/opensky/config_flow.py @@ -0,0 +1,77 @@ +"""Config flow for OpenSky integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import ( + CONF_LATITUDE, + CONF_LONGITUDE, + CONF_NAME, + CONF_RADIUS, +) +from homeassistant.data_entry_flow import FlowResult +import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.typing import ConfigType + +from .const import DEFAULT_NAME, DOMAIN +from .sensor import CONF_ALTITUDE, DEFAULT_ALTITUDE + + +class OpenSkyConfigFlowHandler(ConfigFlow, domain=DOMAIN): + """Config flow handler for OpenSky.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Initialize user input.""" + if user_input is not None: + return self.async_create_entry( + title=DEFAULT_NAME, + data={ + CONF_LATITUDE: user_input[CONF_LATITUDE], + CONF_LONGITUDE: user_input[CONF_LONGITUDE], + }, + options={ + CONF_RADIUS: user_input[CONF_RADIUS], + CONF_ALTITUDE: user_input[CONF_ALTITUDE], + }, + ) + return self.async_show_form( + step_id="user", + data_schema=self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_RADIUS): vol.Coerce(float), + vol.Required(CONF_LATITUDE): cv.latitude, + vol.Required(CONF_LONGITUDE): cv.longitude, + vol.Optional(CONF_ALTITUDE): vol.Coerce(float), + } + ), + { + CONF_LATITUDE: self.hass.config.latitude, + CONF_LONGITUDE: self.hass.config.longitude, + CONF_ALTITUDE: DEFAULT_ALTITUDE, + }, + ), + ) + + async def async_step_import(self, import_config: ConfigType) -> FlowResult: + """Import config from yaml.""" + entry_data = { + CONF_LATITUDE: import_config.get(CONF_LATITUDE, self.hass.config.latitude), + CONF_LONGITUDE: import_config.get( + CONF_LONGITUDE, self.hass.config.longitude + ), + } + self._async_abort_entries_match(entry_data) + return self.async_create_entry( + title=import_config.get(CONF_NAME, DEFAULT_NAME), + data=entry_data, + options={ + CONF_RADIUS: import_config[CONF_RADIUS] * 1000, + CONF_ALTITUDE: import_config.get(CONF_ALTITUDE, DEFAULT_ALTITUDE), + }, + ) diff --git a/homeassistant/components/opensky/const.py b/homeassistant/components/opensky/const.py index 7e511ed7d2c5e7..ccea69f8b7f38a 100644 --- a/homeassistant/components/opensky/const.py +++ b/homeassistant/components/opensky/const.py @@ -1,6 +1,10 @@ """OpenSky constants.""" +from homeassistant.const import Platform + +PLATFORMS = [Platform.SENSOR] DEFAULT_NAME = "OpenSky" DOMAIN = "opensky" +CLIENT = "client" CONF_ALTITUDE = "altitude" ATTR_ICAO24 = "icao24" diff --git a/homeassistant/components/opensky/manifest.json b/homeassistant/components/opensky/manifest.json index 6c6d3acb30eb4e..f3fb13589bb30a 100644 --- a/homeassistant/components/opensky/manifest.json +++ b/homeassistant/components/opensky/manifest.json @@ -2,6 +2,7 @@ "domain": "opensky", "name": "OpenSky Network", "codeowners": ["@joostlek"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/opensky", "iot_class": "cloud_polling", "requirements": ["python-opensky==0.0.10"] diff --git a/homeassistant/components/opensky/sensor.py b/homeassistant/components/opensky/sensor.py index 0616b774951845..4ef1070d12d093 100644 --- a/homeassistant/components/opensky/sensor.py +++ b/homeassistant/components/opensky/sensor.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import ( ATTR_LATITUDE, ATTR_LONGITUDE, @@ -15,10 +16,10 @@ CONF_NAME, CONF_RADIUS, ) -from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.core import DOMAIN as HOMEASSISTANT_DOMAIN, HomeAssistant import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from .const import ( @@ -26,6 +27,7 @@ ATTR_CALLSIGN, ATTR_ICAO24, ATTR_SENSOR, + CLIENT, CONF_ALTITUDE, DEFAULT_ALTITUDE, DOMAIN, @@ -36,6 +38,7 @@ # OpenSky free user has 400 credits, with 4 credits per API call. 100/24 = ~4 requests per hour SCAN_INTERVAL = timedelta(minutes=15) + PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_RADIUS): vol.Coerce(float), @@ -47,27 +50,57 @@ ) -def setup_platform( +async def async_setup_platform( hass: HomeAssistant, config: ConfigType, - add_entities: AddEntitiesCallback, + async_add_entities: AddEntitiesCallback, discovery_info: DiscoveryInfoType | None = None, ) -> None: - """Set up the Open Sky platform.""" - latitude = config.get(CONF_LATITUDE, hass.config.latitude) - longitude = config.get(CONF_LONGITUDE, hass.config.longitude) - radius = config.get(CONF_RADIUS, 0) - bounding_box = OpenSky.get_bounding_box(latitude, longitude, radius * 1000) - session = async_get_clientsession(hass) - opensky = OpenSky(session=session) - add_entities( + """Set up the OpenSky sensor platform from yaml.""" + + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.1.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "OpenSky", + }, + ) + + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + ) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize the entries.""" + + opensky = hass.data[DOMAIN][entry.entry_id][CLIENT] + bounding_box = OpenSky.get_bounding_box( + entry.data[CONF_LATITUDE], + entry.data[CONF_LONGITUDE], + entry.options[CONF_RADIUS], + ) + async_add_entities( [ OpenSkySensor( - hass, - config.get(CONF_NAME, DOMAIN), + entry.title, opensky, bounding_box, - config[CONF_ALTITUDE], + entry.options.get(CONF_ALTITUDE, DEFAULT_ALTITUDE), + entry.entry_id, ) ], True, @@ -83,20 +116,20 @@ class OpenSkySensor(SensorEntity): def __init__( self, - hass: HomeAssistant, name: str, opensky: OpenSky, bounding_box: BoundingBox, altitude: float, + entry_id: str, ) -> None: """Initialize the sensor.""" self._altitude = altitude self._state = 0 - self._hass = hass self._name = name self._previously_tracked: set[str] = set() self._opensky = opensky self._bounding_box = bounding_box + self._attr_unique_id = f"{entry_id}_opensky" @property def name(self) -> str: @@ -133,7 +166,7 @@ def _handle_boundary( ATTR_LATITUDE: latitude, ATTR_ICAO24: icao24, } - self._hass.bus.fire(event, data) + self.hass.bus.fire(event, data) async def async_update(self) -> None: """Update device state.""" diff --git a/homeassistant/components/opensky/strings.json b/homeassistant/components/opensky/strings.json new file mode 100644 index 00000000000000..768ffde155fbdc --- /dev/null +++ b/homeassistant/components/opensky/strings.json @@ -0,0 +1,16 @@ +{ + "config": { + "step": { + "user": { + "description": "Fill in the location to track.", + "data": { + "name": "[%key:common::config_flow::data::api_key%]", + "radius": "Radius", + "latitude": "[%key:common::config_flow::data::latitude%]", + "longitude": "[%key:common::config_flow::data::longitude%]", + "altitude": "Altitude" + } + } + } + } +} diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index b4b9c409c6e45c..2359ac79e040f7 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -325,6 +325,7 @@ "openexchangerates", "opengarage", "openhome", + "opensky", "opentherm_gw", "openuv", "openweathermap", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 6bc96ea15bc070..938ffa13ab541c 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3976,7 +3976,7 @@ "opensky": { "name": "OpenSky Network", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "cloud_polling" }, "opentherm_gw": { diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 0820a3026e195d..d2a9c71103970e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1562,6 +1562,9 @@ python-miio==0.5.12 # homeassistant.components.mystrom python-mystrom==2.2.0 +# homeassistant.components.opensky +python-opensky==0.0.10 + # homeassistant.components.otbr # homeassistant.components.thread python-otbr-api==2.3.0 diff --git a/tests/components/opensky/__init__.py b/tests/components/opensky/__init__.py new file mode 100644 index 00000000000000..f985f068ab10be --- /dev/null +++ b/tests/components/opensky/__init__.py @@ -0,0 +1,9 @@ +"""Opensky tests.""" +from unittest.mock import patch + + +def patch_setup_entry() -> bool: + """Patch interface.""" + return patch( + "homeassistant.components.opensky.async_setup_entry", return_value=True + ) diff --git a/tests/components/opensky/conftest.py b/tests/components/opensky/conftest.py new file mode 100644 index 00000000000000..63e514d0d8fa2f --- /dev/null +++ b/tests/components/opensky/conftest.py @@ -0,0 +1,50 @@ +"""Configure tests for the OpenSky integration.""" +from collections.abc import Awaitable, Callable +from unittest.mock import patch + +import pytest +from python_opensky import StatesResponse + +from homeassistant.components.opensky.const import CONF_ALTITUDE, DOMAIN +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_RADIUS +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + +ComponentSetup = Callable[[MockConfigEntry], Awaitable[None]] + + +@pytest.fixture(name="config_entry") +def mock_config_entry() -> MockConfigEntry: + """Create OpenSky entry in Home Assistant.""" + return MockConfigEntry( + domain=DOMAIN, + title="OpenSky", + data={ + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + }, + options={ + CONF_RADIUS: 10.0, + CONF_ALTITUDE: 0.0, + }, + ) + + +@pytest.fixture(name="setup_integration") +async def mock_setup_integration( + hass: HomeAssistant, +) -> Callable[[MockConfigEntry], Awaitable[None]]: + """Fixture for setting up the component.""" + + async def func(mock_config_entry: MockConfigEntry) -> None: + mock_config_entry.add_to_hass(hass) + with patch( + "python_opensky.OpenSky.get_states", + return_value=StatesResponse(states=[], time=0), + ): + assert await async_setup_component(hass, DOMAIN, {}) + await hass.async_block_till_done() + + return func diff --git a/tests/components/opensky/test_config_flow.py b/tests/components/opensky/test_config_flow.py new file mode 100644 index 00000000000000..e785a5f3a8fbae --- /dev/null +++ b/tests/components/opensky/test_config_flow.py @@ -0,0 +1,155 @@ +"""Test OpenSky config flow.""" +from typing import Any + +import pytest + +from homeassistant.components.opensky.const import ( + CONF_ALTITUDE, + DEFAULT_NAME, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, CONF_RADIUS +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import patch_setup_entry + +from tests.common import MockConfigEntry + + +async def test_full_user_flow(hass: HomeAssistant) -> None: + """Test the full user configuration flow.""" + with patch_setup_entry(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={ + CONF_RADIUS: 10, + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + CONF_ALTITUDE: 0, + }, + ) + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "OpenSky" + assert result["data"] == { + CONF_LATITUDE: 0.0, + CONF_LONGITUDE: 0.0, + } + assert result["options"] == { + CONF_ALTITUDE: 0.0, + CONF_RADIUS: 10.0, + } + + +@pytest.mark.parametrize( + ("config", "title", "data", "options"), + [ + ( + {CONF_RADIUS: 10.0}, + DEFAULT_NAME, + { + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + }, + { + CONF_RADIUS: 10000.0, + CONF_ALTITUDE: 0, + }, + ), + ( + { + CONF_RADIUS: 10.0, + CONF_NAME: "My home", + }, + "My home", + { + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + }, + { + CONF_RADIUS: 10000.0, + CONF_ALTITUDE: 0, + }, + ), + ( + { + CONF_RADIUS: 10.0, + CONF_LATITUDE: 10.0, + CONF_LONGITUDE: -100.0, + }, + DEFAULT_NAME, + { + CONF_LATITUDE: 10.0, + CONF_LONGITUDE: -100.0, + }, + { + CONF_RADIUS: 10000.0, + CONF_ALTITUDE: 0, + }, + ), + ( + {CONF_RADIUS: 10.0, CONF_ALTITUDE: 100.0}, + DEFAULT_NAME, + { + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + }, + { + CONF_RADIUS: 10000.0, + CONF_ALTITUDE: 100.0, + }, + ), + ], +) +async def test_import_flow( + hass: HomeAssistant, + config: dict[str, Any], + title: str, + data: dict[str, Any], + options: dict[str, Any], +) -> None: + """Test the import flow.""" + with patch_setup_entry(): + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_IMPORT}, data=config + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == title + assert result["options"] == options + assert result["data"] == data + + +async def test_importing_already_exists_flow(hass: HomeAssistant) -> None: + """Test the import flow when same location already exists.""" + MockConfigEntry( + domain=DOMAIN, + title=DEFAULT_NAME, + data={}, + options={ + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + CONF_RADIUS: 10.0, + CONF_ALTITUDE: 100.0, + }, + ).add_to_hass(hass) + with patch_setup_entry(): + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={ + CONF_LATITUDE: 32.87336, + CONF_LONGITUDE: -117.22743, + CONF_RADIUS: 10.0, + CONF_ALTITUDE: 100.0, + }, + ) + await hass.async_block_till_done() + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/opensky/test_init.py b/tests/components/opensky/test_init.py new file mode 100644 index 00000000000000..be1c21627f09d0 --- /dev/null +++ b/tests/components/opensky/test_init.py @@ -0,0 +1,28 @@ +"""Test OpenSky component setup process.""" +from __future__ import annotations + +from homeassistant.components.opensky.const import DOMAIN +from homeassistant.core import HomeAssistant + +from .conftest import ComponentSetup + +from tests.common import MockConfigEntry + + +async def test_load_unload_entry( + hass: HomeAssistant, + setup_integration: ComponentSetup, + config_entry: MockConfigEntry, +) -> None: + """Test load and unload entry.""" + await setup_integration(config_entry) + entry = hass.config_entries.async_entries(DOMAIN)[0] + + state = hass.states.get("sensor.opensky") + assert state + + await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + state = hass.states.get("sensor.opensky") + assert not state diff --git a/tests/components/opensky/test_sensor.py b/tests/components/opensky/test_sensor.py new file mode 100644 index 00000000000000..1768efebc78f36 --- /dev/null +++ b/tests/components/opensky/test_sensor.py @@ -0,0 +1,20 @@ +"""OpenSky sensor tests.""" +from homeassistant.components.opensky.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_PLATFORM, CONF_RADIUS, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +LEGACY_CONFIG = {Platform.SENSOR: [{CONF_PLATFORM: DOMAIN, CONF_RADIUS: 10.0}]} + + +async def test_legacy_migration(hass: HomeAssistant) -> None: + """Test migration from yaml to config flow.""" + assert await async_setup_component(hass, Platform.SENSOR, LEGACY_CONFIG) + await hass.async_block_till_done() + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + assert entries[0].state is ConfigEntryState.LOADED + issue_registry = ir.async_get(hass) + assert len(issue_registry.issues) == 1