From 9d42a425fc6d88482161d93f78c5125a3f6059eb Mon Sep 17 00:00:00 2001 From: Franck Nijhof Date: Mon, 7 Mar 2022 18:58:29 +0100 Subject: [PATCH] Add config flow to Season (#67413) --- CODEOWNERS | 2 + homeassistant/components/season/__init__.py | 17 +++- .../components/season/config_flow.py | 48 ++++++++++ homeassistant/components/season/const.py | 14 +++ homeassistant/components/season/manifest.json | 5 +- homeassistant/components/season/sensor.py | 53 +++++------ homeassistant/components/season/strings.json | 14 +++ .../components/season/translations/en.json | 14 +++ .../season/translations/sensor.en.json | 6 -- homeassistant/generated/config_flows.py | 1 + tests/components/season/conftest.py | 30 +++++++ tests/components/season/test_config_flow.py | 76 ++++++++++++++++ tests/components/season/test_init.py | 55 ++++++++++++ tests/components/season/test_sensor.py | 88 ++++++++++++------- 14 files changed, 356 insertions(+), 67 deletions(-) create mode 100644 homeassistant/components/season/config_flow.py create mode 100644 homeassistant/components/season/const.py create mode 100644 homeassistant/components/season/strings.json create mode 100644 homeassistant/components/season/translations/en.json create mode 100644 tests/components/season/conftest.py create mode 100644 tests/components/season/test_config_flow.py create mode 100644 tests/components/season/test_init.py diff --git a/CODEOWNERS b/CODEOWNERS index 1c725643535..79a9241955e 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -859,6 +859,8 @@ homeassistant/components/script/* @home-assistant/core tests/components/script/* @home-assistant/core homeassistant/components/search/* @home-assistant/core tests/components/search/* @home-assistant/core +homeassistant/components/season/* @frenck +tests/components/season/* @frenck homeassistant/components/select/* @home-assistant/core tests/components/select/* @home-assistant/core homeassistant/components/sense/* @kbickar diff --git a/homeassistant/components/season/__init__.py b/homeassistant/components/season/__init__.py index 935270486df..6d4a2974522 100644 --- a/homeassistant/components/season/__init__.py +++ b/homeassistant/components/season/__init__.py @@ -1 +1,16 @@ -"""The season integration.""" +"""The Season integration.""" +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant + +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up from a config entry.""" + hass.config_entries.async_setup_platforms(entry, PLATFORMS) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/season/config_flow.py b/homeassistant/components/season/config_flow.py new file mode 100644 index 00000000000..854c0158439 --- /dev/null +++ b/homeassistant/components/season/config_flow.py @@ -0,0 +1,48 @@ +"""Config flow to configure the Season integration.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow +from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.data_entry_flow import FlowResult + +from .const import DEFAULT_NAME, DOMAIN, TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL + + +class SeasonConfigFlow(ConfigFlow, domain=DOMAIN): + """Config flow for Season.""" + + VERSION = 1 + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> FlowResult: + """Handle a flow initialized by the user.""" + if user_input is not None: + await self.async_set_unique_id(user_input[CONF_TYPE]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input.get(CONF_NAME, DEFAULT_NAME), + data={CONF_TYPE: user_input[CONF_TYPE]}, + ) + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + { + vol.Required(CONF_TYPE, default=TYPE_ASTRONOMICAL): vol.In( + { + TYPE_ASTRONOMICAL: "Astronomical", + TYPE_METEOROLOGICAL: "Meteorological", + } + ) + }, + ), + ) + + async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: + """Handle import from configuration.yaml.""" + return await self.async_step_user(user_input) diff --git a/homeassistant/components/season/const.py b/homeassistant/components/season/const.py new file mode 100644 index 00000000000..c27d4f5c40e --- /dev/null +++ b/homeassistant/components/season/const.py @@ -0,0 +1,14 @@ +"""Constants for the Season integration.""" +from typing import Final + +from homeassistant.const import Platform + +DOMAIN: Final = "season" +PLATFORMS: Final = [Platform.SENSOR] + +DEFAULT_NAME: Final = "Season" + +TYPE_ASTRONOMICAL: Final = "astronomical" +TYPE_METEOROLOGICAL: Final = "meteorological" + +VALID_TYPES: Final = [TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL] diff --git a/homeassistant/components/season/manifest.json b/homeassistant/components/season/manifest.json index 77059619940..7b6feeca8a4 100644 --- a/homeassistant/components/season/manifest.json +++ b/homeassistant/components/season/manifest.json @@ -3,8 +3,9 @@ "name": "Season", "documentation": "https://www.home-assistant.io/integrations/season", "requirements": ["ephem==4.1.2"], - "codeowners": [], + "codeowners": ["@frenck"], "quality_scale": "internal", "iot_class": "local_polling", - "loggers": ["ephem"] + "loggers": ["ephem"], + "config_flow": true } diff --git a/homeassistant/components/season/sensor.py b/homeassistant/components/season/sensor.py index 23b50c0939f..216475f0cdf 100644 --- a/homeassistant/components/season/sensor.py +++ b/homeassistant/components/season/sensor.py @@ -1,8 +1,7 @@ -"""Support for tracking which astronomical or meteorological season it is.""" +"""Support for Season sensors.""" from __future__ import annotations from datetime import date, datetime -import logging import ephem import voluptuous as vol @@ -11,6 +10,7 @@ from homeassistant.components.sensor import ( PLATFORM_SCHEMA as PARENT_PLATFORM_SCHEMA, SensorEntity, ) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_NAME, CONF_TYPE from homeassistant.core import HomeAssistant import homeassistant.helpers.config_validation as cv @@ -18,9 +18,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util.dt import utcnow -_LOGGER = logging.getLogger(__name__) - -DEFAULT_NAME = "Season" +from .const import DEFAULT_NAME, DOMAIN, TYPE_ASTRONOMICAL, VALID_TYPES EQUATOR = "equator" @@ -32,11 +30,6 @@ STATE_SPRING = "spring" STATE_SUMMER = "summer" STATE_WINTER = "winter" -TYPE_ASTRONOMICAL = "astronomical" -TYPE_METEOROLOGICAL = "meteorological" - -VALID_TYPES = [TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL] - HEMISPHERE_SEASON_SWAP = { STATE_WINTER: STATE_SUMMER, STATE_SPRING: STATE_AUTUMN, @@ -60,25 +53,35 @@ PLATFORM_SCHEMA = PARENT_PLATFORM_SCHEMA.extend( ) -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: - """Display the current season.""" - _type: str = config[CONF_TYPE] - name: str = config[CONF_NAME] + """Set up the season sensor platform.""" + 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: + """Set up the platform from config entry.""" + hemisphere = EQUATOR if hass.config.latitude < 0: hemisphere = SOUTHERN elif hass.config.latitude > 0: hemisphere = NORTHERN - else: - hemisphere = EQUATOR - _LOGGER.debug(_type) - add_entities([Season(hemisphere, _type, name)], True) + async_add_entities([SeasonSensorEntity(entry, hemisphere)], True) def get_season( @@ -100,14 +103,13 @@ def get_season( autumn_start = spring_start.replace(month=9) winter_start = spring_start.replace(month=12) + season = STATE_WINTER if spring_start <= current_date < summer_start: season = STATE_SPRING elif summer_start <= current_date < autumn_start: season = STATE_SUMMER elif autumn_start <= current_date < winter_start: season = STATE_AUTUMN - elif winter_start <= current_date or spring_start > current_date: - season = STATE_WINTER # If user is located in the southern hemisphere swap the season if hemisphere == NORTHERN: @@ -115,16 +117,17 @@ def get_season( return HEMISPHERE_SEASON_SWAP.get(season) -class Season(SensorEntity): +class SeasonSensorEntity(SensorEntity): """Representation of the current season.""" _attr_device_class = "season__season" - def __init__(self, hemisphere: str, season_tracking_type: str, name: str) -> None: + def __init__(self, entry: ConfigEntry, hemisphere: str) -> None: """Initialize the season.""" - self._attr_name = name + self._attr_name = entry.title + self._attr_unique_id = entry.entry_id self.hemisphere = hemisphere - self.type = season_tracking_type + self.type = entry.data[CONF_TYPE] def update(self) -> None: """Update season.""" diff --git a/homeassistant/components/season/strings.json b/homeassistant/components/season/strings.json new file mode 100644 index 00000000000..c75c0f1c507 --- /dev/null +++ b/homeassistant/components/season/strings.json @@ -0,0 +1,14 @@ +{ + "config": { + "step": { + "user": { + "data": { + "type": "Type of season definition" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_service%]" + } + } +} diff --git a/homeassistant/components/season/translations/en.json b/homeassistant/components/season/translations/en.json new file mode 100644 index 00000000000..1638f3c0a20 --- /dev/null +++ b/homeassistant/components/season/translations/en.json @@ -0,0 +1,14 @@ +{ + "config": { + "abort": { + "already_configured": "Service is already configured" + }, + "step": { + "user": { + "data": { + "type": "Type of season definition" + } + } + } + } +} \ No newline at end of file diff --git a/homeassistant/components/season/translations/sensor.en.json b/homeassistant/components/season/translations/sensor.en.json index 54e0ad8e98f..91c7ac12bfc 100644 --- a/homeassistant/components/season/translations/sensor.en.json +++ b/homeassistant/components/season/translations/sensor.en.json @@ -5,12 +5,6 @@ "spring": "Spring", "summer": "Summer", "winter": "Winter" - }, - "season__season__": { - "autumn": "Autumn", - "spring": "Spring", - "summer": "Summer", - "winter": "Winter" } } } \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 379adad2b93..968a52d8269 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -283,6 +283,7 @@ FLOWS = [ "ruckus_unleashed", "samsungtv", "screenlogic", + "season", "sense", "senseme", "sensibo", diff --git a/tests/components/season/conftest.py b/tests/components/season/conftest.py new file mode 100644 index 00000000000..40d95f3331b --- /dev/null +++ b/tests/components/season/conftest.py @@ -0,0 +1,30 @@ +"""Fixtures for Season integration tests.""" +from __future__ import annotations + +from collections.abc import Generator +from unittest.mock import patch + +import pytest + +from homeassistant.components.season.const import DOMAIN, TYPE_ASTRONOMICAL +from homeassistant.const import CONF_TYPE + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + title="Season", + domain=DOMAIN, + data={CONF_TYPE: TYPE_ASTRONOMICAL}, + unique_id=TYPE_ASTRONOMICAL, + ) + + +@pytest.fixture +def mock_setup_entry() -> Generator[None, None, None]: + """Mock setting up a config entry.""" + with patch("homeassistant.components.season.async_setup_entry", return_value=True): + yield diff --git a/tests/components/season/test_config_flow.py b/tests/components/season/test_config_flow.py new file mode 100644 index 00000000000..11ebea8f6d6 --- /dev/null +++ b/tests/components/season/test_config_flow.py @@ -0,0 +1,76 @@ +"""Tests for the Season config flow.""" +from unittest.mock import MagicMock + +import pytest + +from homeassistant.components.season.const import ( + DOMAIN, + TYPE_ASTRONOMICAL, + TYPE_METEOROLOGICAL, +) +from homeassistant.config_entries import SOURCE_IMPORT, SOURCE_USER +from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import ( + RESULT_TYPE_ABORT, + RESULT_TYPE_CREATE_ENTRY, + RESULT_TYPE_FORM, +) + +from tests.common import MockConfigEntry + + +async def test_full_user_flow( + hass: HomeAssistant, + mock_setup_entry: MagicMock, +) -> None: + """Test the full user configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result.get("type") == RESULT_TYPE_FORM + assert result.get("step_id") == SOURCE_USER + assert "flow_id" in result + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_TYPE: TYPE_ASTRONOMICAL}, + ) + + assert result2.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result2.get("title") == "Season" + assert result2.get("data") == {CONF_TYPE: TYPE_ASTRONOMICAL} + + +@pytest.mark.parametrize("source", [SOURCE_USER, SOURCE_IMPORT]) +async def test_single_instance_allowed( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + source: str, +) -> None: + """Test we abort if already setup.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": source}, data={CONF_TYPE: TYPE_ASTRONOMICAL} + ) + + assert result.get("type") == RESULT_TYPE_ABORT + assert result.get("reason") == "already_configured" + + +async def test_import_flow( + hass: HomeAssistant, + mock_setup_entry: MagicMock, +) -> None: + """Test the import configuration flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data={CONF_NAME: "My Seasons", CONF_TYPE: TYPE_METEOROLOGICAL}, + ) + + assert result.get("type") == RESULT_TYPE_CREATE_ENTRY + assert result.get("title") == "My Seasons" + assert result.get("data") == {CONF_TYPE: TYPE_METEOROLOGICAL} diff --git a/tests/components/season/test_init.py b/tests/components/season/test_init.py new file mode 100644 index 00000000000..94012ba16dd --- /dev/null +++ b/tests/components/season/test_init.py @@ -0,0 +1,55 @@ +"""Tests for the Season integration.""" +from unittest.mock import AsyncMock + +from homeassistant.components.season.const import DOMAIN, TYPE_ASTRONOMICAL +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import CONF_NAME, CONF_TYPE +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component + +from tests.common import MockConfigEntry + + +async def test_load_unload_config_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the Season configuration entry loading/unloading.""" + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert not hass.data.get(DOMAIN) + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED + + +async def test_import_config( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, +) -> None: + """Test Season being set up from config via import.""" + assert await async_setup_component( + hass, + SENSOR_DOMAIN, + { + SENSOR_DOMAIN: { + "platform": DOMAIN, + CONF_NAME: "My Season", + } + }, + ) + await hass.async_block_till_done() + + config_entries = hass.config_entries.async_entries(DOMAIN) + assert len(config_entries) == 1 + + entry = config_entries[0] + assert entry.title == "My Season" + assert entry.unique_id == TYPE_ASTRONOMICAL + assert entry.data == {CONF_TYPE: TYPE_ASTRONOMICAL} diff --git a/tests/components/season/test_sensor.py b/tests/components/season/test_sensor.py index 7e673832121..90d01106bba 100644 --- a/tests/components/season/test_sensor.py +++ b/tests/components/season/test_sensor.py @@ -1,19 +1,21 @@ -"""The tests for the Season sensor platform.""" +"""The tests for the Season integration.""" from datetime import datetime -from unittest.mock import patch +from freezegun import freeze_time import pytest +from homeassistant.components.season.const import TYPE_ASTRONOMICAL, TYPE_METEOROLOGICAL from homeassistant.components.season.sensor import ( STATE_AUTUMN, STATE_SPRING, STATE_SUMMER, STATE_WINTER, - TYPE_ASTRONOMICAL, - TYPE_METEOROLOGICAL, ) -from homeassistant.const import STATE_UNKNOWN -from homeassistant.setup import async_setup_component +from homeassistant.const import CONF_TYPE, STATE_UNKNOWN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry HEMISPHERE_NORTHERN = { "homeassistant": {"latitude": 48.864716, "longitude": 2.349014}, @@ -65,60 +67,80 @@ def idfn(val): @pytest.mark.parametrize("type,day,expected", NORTHERN_PARAMETERS, ids=idfn) -async def test_season_northern_hemisphere(hass, type, day, expected): +async def test_season_northern_hemisphere( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + type: str, + day: datetime, + expected: str, +) -> None: """Test that season should be summer.""" hass.config.latitude = HEMISPHERE_NORTHERN["homeassistant"]["latitude"] + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, unique_id=type, data={CONF_TYPE: type} + ) - config = { - **HEMISPHERE_NORTHERN, - "sensor": {"platform": "season", "type": type}, - } - - with patch("homeassistant.components.season.sensor.utcnow", return_value=day): - assert await async_setup_component(hass, "sensor", config) + with freeze_time(day): + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() state = hass.states.get("sensor.season") assert state assert state.state == expected + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("sensor.season") + assert entry + assert entry.unique_id == mock_config_entry.entry_id + @pytest.mark.parametrize("type,day,expected", SOUTHERN_PARAMETERS, ids=idfn) -async def test_season_southern_hemisphere(hass, type, day, expected): +async def test_season_southern_hemisphere( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + type: str, + day: datetime, + expected: str, +) -> None: """Test that season should be summer.""" hass.config.latitude = HEMISPHERE_SOUTHERN["homeassistant"]["latitude"] + mock_config_entry.add_to_hass(hass) + hass.config_entries.async_update_entry( + mock_config_entry, unique_id=type, data={CONF_TYPE: type} + ) - config = { - **HEMISPHERE_SOUTHERN, - "sensor": {"platform": "season", "type": type}, - } - - with patch("homeassistant.components.season.sensor.utcnow", return_value=day): - assert await async_setup_component(hass, "sensor", config) + with freeze_time(day): + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() state = hass.states.get("sensor.season") assert state assert state.state == expected + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("sensor.season") + assert entry + assert entry.unique_id == mock_config_entry.entry_id -async def test_season_equator(hass): + +async def test_season_equator( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, +) -> None: """Test that season should be unknown for equator.""" hass.config.latitude = HEMISPHERE_EQUATOR["homeassistant"]["latitude"] - day = datetime(2017, 9, 3, 0, 0) + mock_config_entry.add_to_hass(hass) - with patch("homeassistant.components.season.sensor.utcnow", return_value=day): - assert await async_setup_component(hass, "sensor", HEMISPHERE_EQUATOR) + with freeze_time(datetime(2017, 9, 3, 0, 0)): + await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() state = hass.states.get("sensor.season") assert state assert state.state == STATE_UNKNOWN - -async def test_setup_hemisphere_empty(hass): - """Test platform setup of missing latlong.""" - hass.config.latitude = None - assert await async_setup_component(hass, "sensor", HEMISPHERE_EMPTY) - await hass.async_block_till_done() - assert hass.config.as_dict()["latitude"] is None + entity_registry = er.async_get(hass) + entry = entity_registry.async_get("sensor.season") + assert entry + assert entry.unique_id == mock_config_entry.entry_id