From 5a58f1c2949d1fa183cef75628030650fddd0c80 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 18 Nov 2023 19:25:25 +0000 Subject: [PATCH 01/20] Add config flow for Time & Date --- .../components/time_date/__init__.py | 36 +++++ .../components/time_date/config_flow.py | 75 +++++++++++ homeassistant/components/time_date/const.py | 16 +++ .../components/time_date/manifest.json | 1 + homeassistant/components/time_date/sensor.py | 83 ++++++++---- .../components/time_date/strings.json | 66 ++++++++++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 2 +- tests/components/time_date/__init__.py | 29 ++++ tests/components/time_date/conftest.py | 14 ++ .../components/time_date/test_config_flow.py | 124 ++++++++++++++++++ tests/components/time_date/test_init.py | 35 +++++ tests/components/time_date/test_sensor.py | 30 ++--- 13 files changed, 463 insertions(+), 49 deletions(-) create mode 100644 homeassistant/components/time_date/config_flow.py create mode 100644 tests/components/time_date/conftest.py create mode 100644 tests/components/time_date/test_config_flow.py create mode 100644 tests/components/time_date/test_init.py diff --git a/homeassistant/components/time_date/__init__.py b/homeassistant/components/time_date/__init__.py index 25e6fa14f396f0..4845a7fb13c718 100644 --- a/homeassistant/components/time_date/__init__.py +++ b/homeassistant/components/time_date/__init__.py @@ -1 +1,37 @@ """The time_date component.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from .const import PLATFORMS + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Time & Date from a config entry.""" + await remove_not_used_entities(hass, entry) + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + entry.async_on_unload(entry.add_update_listener(async_update_listener)) + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload Time & Date config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + + +async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener for options.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def remove_not_used_entities(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Cleanup entities not selected.""" + entity_reg = er.async_get(hass) + entities = er.async_entries_for_config_entry(entity_reg, entry.entry_id) + for entity in entities: + splitter = entity.entity_id.split(".") + check = splitter[1] + if check not in entry.options["display_options"]: + entity_reg.async_remove(entity.entity_id) diff --git a/homeassistant/components/time_date/config_flow.py b/homeassistant/components/time_date/config_flow.py new file mode 100644 index 00000000000000..5cdc04645d5f68 --- /dev/null +++ b/homeassistant/components/time_date/config_flow.py @@ -0,0 +1,75 @@ +"""Adds config flow for Time & Date integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any + +import voluptuous as vol + +from homeassistant import data_entry_flow +from homeassistant.core import async_get_hass +from homeassistant.helpers.schema_config_entry_flow import ( + SchemaCommonFlowHandler, + SchemaConfigFlowHandler, + SchemaFlowError, + SchemaFlowFormStep, +) +from homeassistant.helpers.selector import ( + SelectSelector, + SelectSelectorConfig, + SelectSelectorMode, +) + +from .const import CONF_DISPLAY_OPTIONS, DOMAIN, OPTION_TYPES + +DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_DISPLAY_OPTIONS): SelectSelector( + SelectSelectorConfig( + options=OPTION_TYPES, + mode=SelectSelectorMode.DROPDOWN, + multiple=True, + translation_key="display_options", + ) + ), + } +) + + +async def validate_input( + handler: SchemaCommonFlowHandler, user_input: dict[str, Any] +) -> dict[str, Any]: + """Validate rest setup.""" + hass = async_get_hass() + if hass.config.time_zone is None: + raise SchemaFlowError("timezone_not_exist") + return user_input + + +CONFIG_FLOW = { + "user": SchemaFlowFormStep( + schema=DATA_SCHEMA, + validate_user_input=validate_input, + ), + "import": SchemaFlowFormStep( + schema=DATA_SCHEMA, + validate_user_input=validate_input, + ), +} +OPTIONS_FLOW = {"init": SchemaFlowFormStep(schema=DATA_SCHEMA)} + + +class TimeDateConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): + """Handle a config flow for Time & Date.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return "Time & Date" + + def async_config_flow_finished(self, options: Mapping[str, Any]) -> None: + """Abort if instance already exist.""" + if self._async_current_entries(): + raise data_entry_flow.AbortFlow("single_instance_allowed") diff --git a/homeassistant/components/time_date/const.py b/homeassistant/components/time_date/const.py index 4d0ff354a6c1c0..dde9497b9a3f68 100644 --- a/homeassistant/components/time_date/const.py +++ b/homeassistant/components/time_date/const.py @@ -3,4 +3,20 @@ from typing import Final +from homeassistant.const import Platform + +CONF_DISPLAY_OPTIONS = "display_options" DOMAIN: Final = "time_date" +PLATFORMS = [Platform.SENSOR] +TIME_STR_FORMAT = "%H:%M" + +OPTION_TYPES = [ + "time", + "date", + "date_time", + "date_time_utc", + "date_time_iso", + "time_date", + "beat", + "time_utc", +] diff --git a/homeassistant/components/time_date/manifest.json b/homeassistant/components/time_date/manifest.json index 9d625b8587e07b..e756ec78f94c39 100644 --- a/homeassistant/components/time_date/manifest.json +++ b/homeassistant/components/time_date/manifest.json @@ -2,6 +2,7 @@ "domain": "time_date", "name": "Time & Date", "codeowners": ["@fabaff"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/time_date", "iot_class": "local_push", "quality_scale": "internal" diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index c00d362428b792..fa214fe478e491 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -6,32 +6,34 @@ import voluptuous as vol -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity +from homeassistant.components.sensor import ( + ENTITY_ID_FORMAT, + PLATFORM_SCHEMA, + SensorEntity, +) +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry from homeassistant.const import CONF_DISPLAY_OPTIONS, EVENT_CORE_CONFIG_UPDATE -from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback +from homeassistant.core import ( + CALLBACK_TYPE, + DOMAIN as HOMEASSISTANT_DOMAIN, + Event, + HomeAssistant, + callback, +) import homeassistant.helpers.config_validation as cv +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from .const import DOMAIN +from .const import DOMAIN, OPTION_TYPES _LOGGER = logging.getLogger(__name__) TIME_STR_FORMAT = "%H:%M" -OPTION_TYPES = { - "time": "Time", - "date": "Date", - "date_time": "Date & Time", - "date_time_utc": "Date & Time (UTC)", - "date_time_iso": "Date & Time (ISO)", - "time_date": "Time & Date", - "beat": "Internet Time", - "time_utc": "Time (UTC)", -} PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { @@ -49,11 +51,34 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Time and Date sensor.""" - if hass.config.time_zone is None: - _LOGGER.error("Timezone is not set in Home Assistant configuration") # type: ignore[unreachable] - return False + async_create_issue( + hass, + HOMEASSISTANT_DOMAIN, + f"deprecated_yaml_{DOMAIN}", + breaks_in_ha_version="2024.6.0", + is_fixable=False, + issue_domain=DOMAIN, + severity=IssueSeverity.WARNING, + translation_key="deprecated_yaml", + translation_placeholders={ + "domain": DOMAIN, + "integration_title": "Time & Date", + }, + ) + hass.async_create_task( + hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_IMPORT}, + data=config, + ) + ) - if "beat" in config[CONF_DISPLAY_OPTIONS]: + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Time & Date sensor.""" + if "beat" in entry.options[CONF_DISPLAY_OPTIONS]: async_create_issue( hass, DOMAIN, @@ -70,9 +95,10 @@ async def async_setup_platform( ) _LOGGER.warning("'beat': is deprecated and will be removed in version 2024.7") - async_add_entities( - [TimeDateSensor(hass, variable) for variable in config[CONF_DISPLAY_OPTIONS]] - ) + entities = [] + for option_type in entry.options[CONF_DISPLAY_OPTIONS]: + entities.append(TimeDateSensor(option_type, entry.entry_id)) + async_add_entities(entities) class TimeDateSensor(SensorEntity): @@ -82,16 +108,21 @@ class TimeDateSensor(SensorEntity): def __init__(self, hass: HomeAssistant, option_type: str) -> None: """Initialize the sensor.""" - self._name = OPTION_TYPES[option_type] + self._attr_translation_key = option_type self.type = option_type self._state: str | None = None - self.hass = hass self.unsub: CALLBACK_TYPE | None = None - @property - def name(self) -> str: - """Return the name of the sensor.""" - return self._name + object_id = "internet_time" if option_type == "beat" else option_type + self.entity_id = ENTITY_ID_FORMAT.format(object_id) + self._attr_unique_id = option_type + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, DOMAIN)}, + name="Time & Date", + ) + + self._update_internal_state(dt_util.utcnow()) @property def native_value(self) -> str | None: diff --git a/homeassistant/components/time_date/strings.json b/homeassistant/components/time_date/strings.json index 582fd44a45bf57..657cd8b6eb0fc1 100644 --- a/homeassistant/components/time_date/strings.json +++ b/homeassistant/components/time_date/strings.json @@ -1,4 +1,70 @@ { + "config": { + "abort": { + "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + }, + "step": { + "user": { + "data": { + "display_options": "Sensors" + } + } + }, + "error": { + "timezone_not_exist": "Timezone is not set in Home Assistant configuration" + } + }, + "options": { + "step": { + "init": { + "data": { + "display_options": "[%key:component::time_date::config::step::user::data::display_options%]" + } + } + } + }, + "selector": { + "display_options": { + "options": { + "time": "Time", + "date": "Date", + "date_time": "Date & Time", + "date_time_utc": "Date & Time (UTC)", + "date_time_iso": "Date & Time (ISO)", + "time_date": "Time & Date", + "beat": "Internet time", + "time_utc": "Time (UTC)" + } + } + }, + "entity": { + "sensor": { + "time": { + "name": "[%key:component::time_date::selector::display_options::options::time%]" + }, + "date": { + "name": "[%key:component::time_date::selector::display_options::options::date%]" + }, + "date_time": { + "name": "[%key:component::time_date::selector::display_options::options::date_time%]" + }, + "date_time_utc": { + "name": "[%key:component::time_date::selector::display_options::options::date_time_utc%]" + }, + "date_time_iso": { + "name": "[%key:component::time_date::selector::display_options::options::date_time_iso%]" + }, + "time_date": { + "name": "[%key:component::time_date::selector::display_options::options::time_date%]" + }, + "beat": { + "name": "[%key:component::time_date::selector::display_options::options::beat%]" + }, + "time_utc": { + "name": "[%key:component::time_date::selector::display_options::options::time_utc%]" + } + } + }, "issues": { "deprecated_beat": { "title": "The `{config_key}` Time & Date sensor is being removed", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 254a3ad0df3c05..fe5e746a8355b0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -510,6 +510,7 @@ "tibber", "tile", "tilt_ble", + "time_date", "todoist", "tolo", "tomorrowio", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c55b6aecce9712..bce252e5bb83fd 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5977,7 +5977,7 @@ "time_date": { "name": "Time & Date", "integration_type": "hub", - "config_flow": false, + "config_flow": true, "iot_class": "local_push" }, "tmb": { diff --git a/tests/components/time_date/__init__.py b/tests/components/time_date/__init__.py index 22734c19bbb85d..90c9a799b8f965 100644 --- a/tests/components/time_date/__init__.py +++ b/tests/components/time_date/__init__.py @@ -1 +1,30 @@ """Tests for the time_date component.""" + +from homeassistant.components.time_date.const import DOMAIN, OPTION_TYPES +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_DISPLAY_OPTIONS +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def load_int( + hass: HomeAssistant, display_options: list[str] | None = None +) -> MockConfigEntry: + """Set up the Time & Date integration in Home Assistant.""" + if display_options is None: + display_options = OPTION_TYPES + config_entry = MockConfigEntry( + domain=DOMAIN, + source=SOURCE_USER, + data={}, + options={CONF_DISPLAY_OPTIONS: display_options}, + entry_id="1234567890", + ) + + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + return config_entry diff --git a/tests/components/time_date/conftest.py b/tests/components/time_date/conftest.py new file mode 100644 index 00000000000000..af732f978b4a58 --- /dev/null +++ b/tests/components/time_date/conftest.py @@ -0,0 +1,14 @@ +"""Fixtures for Time & Date integration tests.""" +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock, None, None]: + """Mock setting up a config entry.""" + with patch( + "homeassistant.components.time_date.async_setup_entry", return_value=True + ) as mock_setup: + yield mock_setup diff --git a/tests/components/time_date/test_config_flow.py b/tests/components/time_date/test_config_flow.py new file mode 100644 index 00000000000000..4ebe0260e4b004 --- /dev/null +++ b/tests/components/time_date/test_config_flow.py @@ -0,0 +1,124 @@ +"""Test the Time & Date config flow.""" +from __future__ import annotations + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant import config_entries +from homeassistant.components.time_date.const import CONF_DISPLAY_OPTIONS, DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + +pytestmark = pytest.mark.usefixtures("mock_setup_entry") + + +async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: + """Test we get the forms.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"display_options": ["time"]}, + ) + await hass.async_block_till_done() + + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == FlowResultType.CREATE_ENTRY + + +async def test_single_instance(hass: HomeAssistant) -> None: + """Test we get the forms.""" + + entry = MockConfigEntry( + domain=DOMAIN, data={}, options={CONF_DISPLAY_OPTIONS: ["time", "date"]} + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"display_options": ["time"]}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_import_flow_success(hass: HomeAssistant) -> None: + """Test a successful import of yaml.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_DISPLAY_OPTIONS: ["time", "date"]}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["title"] == "Time & Date" + assert result["options"] == {"display_options": ["time", "date"]} + + +async def test_import_flow_already_exist(hass: HomeAssistant) -> None: + """Test import of yaml already exist.""" + + entry = MockConfigEntry( + domain=DOMAIN, data={}, options={CONF_DISPLAY_OPTIONS: ["time", "date"]} + ) + entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_IMPORT}, + data={CONF_DISPLAY_OPTIONS: ["time", "date"]}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.ABORT + assert result["reason"] == "single_instance_allowed" + + +async def test_timezone_not_set(hass: HomeAssistant) -> None: + """Test time zone not set.""" + hass.config.time_zone = None + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"display_options": ["time"]}, + ) + await hass.async_block_till_done() + + assert result["type"] == FlowResultType.FORM + assert result["errors"] == {"base": "timezone_not_exist"} + + +async def test_options(hass: HomeAssistant) -> None: + """Test updating options.""" + entry = MockConfigEntry(domain=DOMAIN, data={"display_options": ["time"]}) + entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(entry.entry_id) + + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "init" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"display_options": ["time", "date"]}, + ) + + assert result["type"] == FlowResultType.CREATE_ENTRY + assert result["data"] == {"display_options": ["time", "date"]} diff --git a/tests/components/time_date/test_init.py b/tests/components/time_date/test_init.py new file mode 100644 index 00000000000000..68428209e52817 --- /dev/null +++ b/tests/components/time_date/test_init.py @@ -0,0 +1,35 @@ +"""The tests for the Time & Date component.""" + +from homeassistant.core import HomeAssistant + +from . import load_int + + +async def test_setup_and_remove_config_entry(hass: HomeAssistant) -> None: + """Test setting up and removing a config entry.""" + entry = await load_int(hass) + + state = hass.states.get("sensor.time") + assert state is not None + + assert await hass.config_entries.async_remove(entry.entry_id) + await hass.async_block_till_done() + + assert hass.states.get("sensor.time") is None + + +async def test_reload_config_entry(hass: HomeAssistant) -> None: + """Test setting up and removing a config entry.""" + entry = await load_int(hass) + + state = hass.states.get("sensor.time") + assert state is not None + + result = await hass.config_entries.options.async_init(entry.entry_id) + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={"display_options": ["date"]}, + ) + await hass.async_block_till_done() + + assert hass.states.get("sensor.time") is None diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index e8741a4342776c..7d8bc37fdb4909 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -6,16 +6,13 @@ import pytest from homeassistant.components.time_date.const import DOMAIN -import homeassistant.components.time_date.sensor as time_date from homeassistant.core import HomeAssistant from homeassistant.helpers import event, issue_registry as ir -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util -from tests.common import async_fire_time_changed +from . import load_int -ALL_DISPLAY_OPTIONS = list(time_date.OPTION_TYPES.keys()) -CONFIG = {"sensor": {"platform": "time_date", "display_options": ALL_DISPLAY_OPTIONS}} +from tests.common import async_fire_time_changed @patch("homeassistant.components.time_date.sensor.async_track_point_in_utc_time") @@ -54,12 +51,9 @@ async def test_intervals( ) -> None: """Test timing intervals of sensors when time zone is UTC.""" hass.config.set_time_zone("UTC") - config = {"sensor": {"platform": "time_date", "display_options": [display_option]}} - freezer.move_to(start_time) - await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + await load_int(hass, [display_option]) mock_track_interval.assert_called_once_with(hass, ANY, tracked_time) @@ -70,8 +64,7 @@ async def test_states(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> No now = dt_util.utc_from_timestamp(1495068856) freezer.move_to(now) - await async_setup_component(hass, "sensor", CONFIG) - await hass.async_block_till_done() + await load_int(hass) state = hass.states.get("sensor.time") assert state.state == "00:54" @@ -130,8 +123,7 @@ async def test_states_non_default_timezone( now = dt_util.utc_from_timestamp(1495068856) freezer.move_to(now) - await async_setup_component(hass, "sensor", CONFIG) - await hass.async_block_till_done() + await load_int(hass) state = hass.states.get("sensor.time") assert state.state == "20:54" @@ -262,9 +254,7 @@ async def test_timezone_intervals( hass.config.set_time_zone(time_zone) freezer.move_to(start_time) - config = {"sensor": {"platform": "time_date", "display_options": ["date"]}} - await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + await load_int(hass, ["date"]) mock_track_interval.assert_called_once() next_time = mock_track_interval.mock_calls[0][1][2] @@ -274,8 +264,7 @@ async def test_timezone_intervals( async def test_icons(hass: HomeAssistant) -> None: """Test attributes of sensors.""" - await async_setup_component(hass, "sensor", CONFIG) - await hass.async_block_till_done() + await load_int(hass) state = hass.states.get("sensor.time") assert state.attributes["icon"] == "mdi:clock" @@ -313,10 +302,7 @@ async def test_deprecation_warning( expected_issues: list[str], ) -> None: """Test deprecation warning for swatch beat.""" - config = {"sensor": {"platform": "time_date", "display_options": display_options}} - - await async_setup_component(hass, "sensor", config) - await hass.async_block_till_done() + await load_int(hass, display_options) warnings = [record for record in caplog.records if record.levelname == "WARNING"] assert len(warnings) == len(expected_warnings) From cc3b6ff130e4e6136b90de1d0480aff7942e5db4 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 18 Nov 2023 21:47:12 +0000 Subject: [PATCH 02/20] feedback --- homeassistant/components/time_date/sensor.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index fa214fe478e491..3fa9605ba07613 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -105,14 +105,14 @@ class TimeDateSensor(SensorEntity): """Implementation of a Time and Date sensor.""" _attr_should_poll = False + _attr_has_entity_name = True + _state: str | None = None + unsub: CALLBACK_TYPE | None = None def __init__(self, hass: HomeAssistant, option_type: str) -> None: """Initialize the sensor.""" self._attr_translation_key = option_type self.type = option_type - self._state: str | None = None - self.unsub: CALLBACK_TYPE | None = None - object_id = "internet_time" if option_type == "beat" else option_type self.entity_id = ENTITY_ID_FORMAT.format(object_id) self._attr_unique_id = option_type From b754388cab4226b3acdd8aa5529c3a1420cb6e35 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 2 Jan 2024 17:19:04 +0100 Subject: [PATCH 03/20] Address review comments --- homeassistant/components/time_date/sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 3fa9605ba07613..a3c22077345c56 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -55,7 +55,7 @@ async def async_setup_platform( hass, HOMEASSISTANT_DOMAIN, f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.6.0", + breaks_in_ha_version="2024.7.0", is_fixable=False, issue_domain=DOMAIN, severity=IssueSeverity.WARNING, From 4f18dcef87ed0851d85637bcb08c28216acc3dfb Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 2 Jan 2024 17:25:56 +0100 Subject: [PATCH 04/20] Don't allow beat in user flow --- .../components/time_date/config_flow.py | 21 ++++++++++++--- .../components/time_date/test_config_flow.py | 26 ++++++++++++++++--- 2 files changed, 39 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/time_date/config_flow.py b/homeassistant/components/time_date/config_flow.py index 5cdc04645d5f68..f26d1fd42f7ae3 100644 --- a/homeassistant/components/time_date/config_flow.py +++ b/homeassistant/components/time_date/config_flow.py @@ -22,7 +22,20 @@ from .const import CONF_DISPLAY_OPTIONS, DOMAIN, OPTION_TYPES -DATA_SCHEMA = vol.Schema( +USER_SCHEMA = vol.Schema( + { + vol.Required(CONF_DISPLAY_OPTIONS): SelectSelector( + SelectSelectorConfig( + options=[option for option in OPTION_TYPES if option != "beat"], + mode=SelectSelectorMode.DROPDOWN, + multiple=True, + translation_key="display_options", + ) + ), + } +) + +IMPORT_OPTIONS_SCHEMA = vol.Schema( { vol.Required(CONF_DISPLAY_OPTIONS): SelectSelector( SelectSelectorConfig( @@ -48,15 +61,15 @@ async def validate_input( CONFIG_FLOW = { "user": SchemaFlowFormStep( - schema=DATA_SCHEMA, + schema=USER_SCHEMA, validate_user_input=validate_input, ), "import": SchemaFlowFormStep( - schema=DATA_SCHEMA, + schema=IMPORT_OPTIONS_SCHEMA, validate_user_input=validate_input, ), } -OPTIONS_FLOW = {"init": SchemaFlowFormStep(schema=DATA_SCHEMA)} +OPTIONS_FLOW = {"init": SchemaFlowFormStep(schema=IMPORT_OPTIONS_SCHEMA)} class TimeDateConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): diff --git a/tests/components/time_date/test_config_flow.py b/tests/components/time_date/test_config_flow.py index 4ebe0260e4b004..27feb8e7174239 100644 --- a/tests/components/time_date/test_config_flow.py +++ b/tests/components/time_date/test_config_flow.py @@ -4,6 +4,7 @@ from unittest.mock import AsyncMock import pytest +import voluptuous as vol from homeassistant import config_entries from homeassistant.components.time_date.const import CONF_DISPLAY_OPTIONS, DOMAIN @@ -33,6 +34,23 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: assert result["type"] == FlowResultType.CREATE_ENTRY +async def test_user_flow_does_not_allow_beat( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test we get the forms.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + + with pytest.raises(vol.Invalid): + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"display_options": ["time", "beat"]}, + ) + + async def test_single_instance(hass: HomeAssistant) -> None: """Test we get the forms.""" @@ -60,13 +78,13 @@ async def test_import_flow_success(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_DISPLAY_OPTIONS: ["time", "date"]}, + data={CONF_DISPLAY_OPTIONS: ["time", "date", "beat"]}, ) await hass.async_block_till_done() assert result["type"] == FlowResultType.CREATE_ENTRY assert result["title"] == "Time & Date" - assert result["options"] == {"display_options": ["time", "date"]} + assert result["options"] == {"display_options": ["time", "date", "beat"]} async def test_import_flow_already_exist(hass: HomeAssistant) -> None: @@ -117,8 +135,8 @@ async def test_options(hass: HomeAssistant) -> None: result = await hass.config_entries.options.async_configure( result["flow_id"], - user_input={"display_options": ["time", "date"]}, + user_input={"display_options": ["time", "date", "beat"]}, ) assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {"display_options": ["time", "date"]} + assert result["data"] == {"display_options": ["time", "date", "beat"]} From b1a9249dd6a19498e3be5476c22012f68908c83a Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 2 Jan 2024 17:41:09 +0100 Subject: [PATCH 05/20] Address review comment --- homeassistant/components/time_date/sensor.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index a3c22077345c56..735d341d255988 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -95,10 +95,12 @@ async def async_setup_entry( ) _LOGGER.warning("'beat': is deprecated and will be removed in version 2024.7") - entities = [] - for option_type in entry.options[CONF_DISPLAY_OPTIONS]: - entities.append(TimeDateSensor(option_type, entry.entry_id)) - async_add_entities(entities) + async_add_entities( + [ + TimeDateSensor(option_type, entry.entry_id) + for option_type in entry.options[CONF_DISPLAY_OPTIONS] + ] + ) class TimeDateSensor(SensorEntity): From 17500ba21d6cf752afa4f77180f4e65b8b20048a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Tue, 2 Jan 2024 19:54:09 +0000 Subject: [PATCH 06/20] Make issue fixable --- homeassistant/components/time_date/sensor.py | 3 +-- homeassistant/components/time_date/strings.json | 9 ++++++++- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 735d341d255988..55b635881d34ae 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -84,12 +84,11 @@ async def async_setup_entry( DOMAIN, "deprecated_beat", breaks_in_ha_version="2024.7.0", - is_fixable=False, + is_fixable=True, severity=IssueSeverity.WARNING, translation_key="deprecated_beat", translation_placeholders={ "config_key": "beat", - "display_options": "display_options", "integration": DOMAIN, }, ) diff --git a/homeassistant/components/time_date/strings.json b/homeassistant/components/time_date/strings.json index 657cd8b6eb0fc1..85ad05cb524583 100644 --- a/homeassistant/components/time_date/strings.json +++ b/homeassistant/components/time_date/strings.json @@ -68,7 +68,14 @@ "issues": { "deprecated_beat": { "title": "The `{config_key}` Time & Date sensor is being removed", - "description": "Please remove the `{config_key}` key from the `{display_options}` for the {integration} entry in your configuration.yaml file and restart Home Assistant to fix this issue." + "fix_flow": { + "step": { + "confirm": { + "title": "[%key:component::time_date::issues::deprecated_beat::title%]", + "description": "Please remove the `{config_key}` key from the {integration} config entry options and click submit to fix this issue." + } + } + } } } } From 48d961b5b4016c198630de4d06aa39fb36db7d13 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 2 Jan 2024 21:57:31 +0100 Subject: [PATCH 07/20] Fix cleanup of unused entities --- homeassistant/components/time_date/__init__.py | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/time_date/__init__.py b/homeassistant/components/time_date/__init__.py index 4845a7fb13c718..76c3007d60e80f 100644 --- a/homeassistant/components/time_date/__init__.py +++ b/homeassistant/components/time_date/__init__.py @@ -10,7 +10,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Time & Date from a config entry.""" - await remove_not_used_entities(hass, entry) + remove_unused_entities(hass, entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) entry.async_on_unload(entry.add_update_listener(async_update_listener)) return True @@ -26,12 +26,10 @@ async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None await hass.config_entries.async_reload(entry.entry_id) -async def remove_not_used_entities(hass: HomeAssistant, entry: ConfigEntry) -> None: +def remove_unused_entities(hass: HomeAssistant, entry: ConfigEntry) -> None: """Cleanup entities not selected.""" entity_reg = er.async_get(hass) entities = er.async_entries_for_config_entry(entity_reg, entry.entry_id) for entity in entities: - splitter = entity.entity_id.split(".") - check = splitter[1] - if check not in entry.options["display_options"]: + if entity.unique_id not in entry.options["display_options"]: entity_reg.async_remove(entity.entity_id) From d0395c140655ffaf76c66503e174c52db49c3f91 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 2 Jan 2024 21:58:19 +0100 Subject: [PATCH 08/20] Add preview --- .../components/time_date/config_flow.py | 105 ++++++++++- homeassistant/components/time_date/sensor.py | 33 ++++ homeassistant/helpers/entity_component.py | 4 +- homeassistant/helpers/entity_platform.py | 91 +++++----- .../components/time_date/test_config_flow.py | 166 +++++++++++++++++- 5 files changed, 352 insertions(+), 47 deletions(-) diff --git a/homeassistant/components/time_date/config_flow.py b/homeassistant/components/time_date/config_flow.py index f26d1fd42f7ae3..dfe5a646e20bba 100644 --- a/homeassistant/components/time_date/config_flow.py +++ b/homeassistant/components/time_date/config_flow.py @@ -2,12 +2,19 @@ from __future__ import annotations from collections.abc import Mapping +from datetime import timedelta +import logging from typing import Any import voluptuous as vol from homeassistant import data_entry_flow -from homeassistant.core import async_get_hass +from homeassistant.components import websocket_api +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, async_get_hass, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, SchemaConfigFlowHandler, @@ -19,8 +26,12 @@ SelectSelectorConfig, SelectSelectorMode, ) +from homeassistant.setup import async_prepare_setup_platform from .const import CONF_DISPLAY_OPTIONS, DOMAIN, OPTION_TYPES +from .sensor import TimeDateSensor + +_LOGGER = logging.getLogger(__name__) USER_SCHEMA = vol.Schema( { @@ -62,6 +73,7 @@ async def validate_input( CONFIG_FLOW = { "user": SchemaFlowFormStep( schema=USER_SCHEMA, + preview=DOMAIN, validate_user_input=validate_input, ), "import": SchemaFlowFormStep( @@ -69,7 +81,9 @@ async def validate_input( validate_user_input=validate_input, ), } -OPTIONS_FLOW = {"init": SchemaFlowFormStep(schema=IMPORT_OPTIONS_SCHEMA)} +OPTIONS_FLOW = { + "init": SchemaFlowFormStep(schema=IMPORT_OPTIONS_SCHEMA, preview=DOMAIN) +} class TimeDateConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): @@ -86,3 +100,90 @@ def async_config_flow_finished(self, options: Mapping[str, Any]) -> None: """Abort if instance already exist.""" if self._async_current_entries(): raise data_entry_flow.AbortFlow("single_instance_allowed") + + @staticmethod + async def async_setup_preview(hass: HomeAssistant) -> None: + """Set up preview WS API.""" + websocket_api.async_register_command(hass, ws_start_preview) + + +@websocket_api.websocket_command( + { + vol.Required("type"): "time_date/start_preview", + vol.Required("flow_id"): str, + vol.Required("flow_type"): vol.Any("config_flow", "options_flow"), + vol.Required("user_input"): dict, + } +) +@websocket_api.async_response +async def ws_start_preview( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Generate a preview.""" + validated = IMPORT_OPTIONS_SCHEMA(msg["user_input"]) + + # Create an EntityPlatform, needed for name translations + platform = await async_prepare_setup_platform(hass, {}, SENSOR_DOMAIN, DOMAIN) + entity_platform = EntityPlatform( + hass=hass, + logger=_LOGGER, + domain=SENSOR_DOMAIN, + platform_name=DOMAIN, + platform=platform, + scan_interval=timedelta(seconds=3600), + entity_namespace=None, + ) + await entity_platform.async_load_translations() + + preview_states: dict[str, dict[str, str | Mapping[str, Any]]] = {} + + @callback + def async_preview_updated( + key: str, state: str, attributes: Mapping[str, Any] + ) -> None: + """Forward config entry state events to websocket.""" + preview_states[key] = {"attributes": attributes, "state": state} + connection.send_message( + websocket_api.event_message( + msg["id"], {"items": list(preview_states.values())} + ) + ) + + subscriptions: list[CALLBACK_TYPE] = [] + + @callback + def async_unsubscripe_subscriptions() -> None: + while subscriptions: + subscriptions.pop()() + + preview_entities = { + option_type: TimeDateSensor(option_type, "preview") + for option_type in validated[CONF_DISPLAY_OPTIONS] + } + + for preview_entity in preview_entities.values(): + preview_entity.hass = hass + preview_entity.platform = entity_platform + + if msg["flow_type"] == "options_flow": + flow_status = hass.config_entries.options.async_get(msg["flow_id"]) + config_entry_id = flow_status["handler"] + config_entry = hass.config_entries.async_get_entry(config_entry_id) + if not config_entry: + raise HomeAssistantError + entity_registry = er.async_get(hass) + entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) + for option_type, preview_entity in preview_entities.items(): + expected_unique_id = option_type + for entry in entries: + if entry.unique_id == expected_unique_id: + preview_entity.registry_entry = entry + break + + connection.send_result(msg["id"]) + for preview_entity in preview_entities.values(): + subscriptions.append(preview_entity.async_start_preview(async_preview_updated)) + + connection.subscriptions[msg["id"]] = async_unsubscripe_subscriptions diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 55b635881d34ae..2d58cfab1029c0 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -1,8 +1,10 @@ """Support for showing the date and the time.""" from __future__ import annotations +from collections.abc import Callable, Mapping from datetime import datetime, timedelta import logging +from typing import Any import voluptuous as vol @@ -139,6 +141,37 @@ def icon(self) -> str: return "mdi:calendar" return "mdi:clock" + @callback + def async_start_preview( + self, + preview_callback: Callable[[str, str, Mapping[str, Any]], None], + ) -> CALLBACK_TYPE: + """Render a preview.""" + + @callback + def point_in_time_listener(time_date: datetime | None) -> None: + """Update preview.""" + + now = dt_util.utcnow() + self._update_internal_state(now) + self.unsub = async_track_point_in_utc_time( + self.hass, point_in_time_listener, self.get_next_interval(now) + ) + calculated_state = self._async_calculate_state() + preview_callback( + self.type, calculated_state.state, calculated_state.attributes + ) + + @callback + def async_stop_preview() -> None: + """Stop preview.""" + if self.unsub: + self.unsub() + self.unsub = None + + point_in_time_listener(None) + return async_stop_preview + async def async_added_to_hass(self) -> None: """Set up first update.""" diff --git a/homeassistant/helpers/entity_component.py b/homeassistant/helpers/entity_component.py index b3eb8722997f60..f9940b526ed096 100644 --- a/homeassistant/helpers/entity_component.py +++ b/homeassistant/helpers/entity_component.py @@ -373,7 +373,7 @@ def _async_init_entity_platform( if scan_interval is None: scan_interval = self.scan_interval - return EntityPlatform( + entity_platform = EntityPlatform( hass=self.hass, logger=self.logger, domain=self.domain, @@ -382,6 +382,8 @@ def _async_init_entity_platform( scan_interval=scan_interval, entity_namespace=entity_namespace, ) + entity_platform.async_prepare() + return entity_platform async def _async_shutdown(self, event: Event) -> None: """Call when Home Assistant is stopping.""" diff --git a/homeassistant/helpers/entity_platform.py b/homeassistant/helpers/entity_platform.py index 1bf7d95135ba87..03e78b6bfeaa68 100644 --- a/homeassistant/helpers/entity_platform.py +++ b/homeassistant/helpers/entity_platform.py @@ -144,10 +144,6 @@ def __init__( # which powers entity_component.add_entities self.parallel_updates_created = platform is None - hass.data.setdefault(DATA_ENTITY_PLATFORM, {}).setdefault( - self.platform_name, [] - ).append(self) - self.domain_entities: dict[str, Entity] = hass.data.setdefault( DATA_DOMAIN_ENTITIES, {} ).setdefault(domain, {}) @@ -309,44 +305,8 @@ async def _async_setup_platform( logger = self.logger hass = self.hass full_name = f"{self.platform_name}.{self.domain}" - object_id_language = ( - hass.config.language - if hass.config.language in languages.NATIVE_ENTITY_IDS - else languages.DEFAULT_LANGUAGE - ) - - async def get_translations( - language: str, category: str, integration: str - ) -> dict[str, Any]: - """Get entity translations.""" - try: - return await translation.async_get_translations( - hass, language, category, {integration} - ) - except Exception as err: # pylint: disable=broad-exception-caught - _LOGGER.debug( - "Could not load translations for %s", - integration, - exc_info=err, - ) - return {} - self.component_translations = await get_translations( - hass.config.language, "entity_component", self.domain - ) - self.platform_translations = await get_translations( - hass.config.language, "entity", self.platform_name - ) - if object_id_language == hass.config.language: - self.object_id_component_translations = self.component_translations - self.object_id_platform_translations = self.platform_translations - else: - self.object_id_component_translations = await get_translations( - object_id_language, "entity_component", self.domain - ) - self.object_id_platform_translations = await get_translations( - object_id_language, "entity", self.platform_name - ) + await self.async_load_translations() logger.info("Setting up %s", full_name) warn_task = hass.loop.call_at( @@ -429,6 +389,48 @@ async def setup_again(*_args: Any) -> None: finally: warn_task.cancel() + async def async_load_translations(self) -> None: + """Load translations.""" + hass = self.hass + object_id_language = ( + hass.config.language + if hass.config.language in languages.NATIVE_ENTITY_IDS + else languages.DEFAULT_LANGUAGE + ) + + async def get_translations( + language: str, category: str, integration: str + ) -> dict[str, Any]: + """Get entity translations.""" + try: + return await translation.async_get_translations( + hass, language, category, {integration} + ) + except Exception as err: # pylint: disable=broad-exception-caught + _LOGGER.debug( + "Could not load translations for %s", + integration, + exc_info=err, + ) + return {} + + self.component_translations = await get_translations( + hass.config.language, "entity_component", self.domain + ) + self.platform_translations = await get_translations( + hass.config.language, "entity", self.platform_name + ) + if object_id_language == hass.config.language: + self.object_id_component_translations = self.component_translations + self.object_id_platform_translations = self.platform_translations + else: + self.object_id_component_translations = await get_translations( + object_id_language, "entity_component", self.domain + ) + self.object_id_platform_translations = await get_translations( + object_id_language, "entity", self.platform_name + ) + def _schedule_add_entities( self, new_entities: Iterable[Entity], update_before_add: bool = False ) -> None: @@ -782,6 +784,13 @@ def async_unsub_polling(self) -> None: self._async_unsub_polling() self._async_unsub_polling = None + @callback + def async_prepare(self) -> None: + """Register the entity platform in DATA_ENTITY_PLATFORM.""" + self.hass.data.setdefault(DATA_ENTITY_PLATFORM, {}).setdefault( + self.platform_name, [] + ).append(self) + async def async_destroy(self) -> None: """Destroy an entity platform. diff --git a/tests/components/time_date/test_config_flow.py b/tests/components/time_date/test_config_flow.py index 27feb8e7174239..b00896369e134a 100644 --- a/tests/components/time_date/test_config_flow.py +++ b/tests/components/time_date/test_config_flow.py @@ -3,6 +3,7 @@ from unittest.mock import AsyncMock +from freezegun.api import FrozenDateTimeFactory import pytest import voluptuous as vol @@ -11,7 +12,8 @@ from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed +from tests.typing import WebSocketGenerator pytestmark = pytest.mark.usefixtures("mock_setup_entry") @@ -62,12 +64,12 @@ async def test_single_instance(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + assert result["type"] == FlowResultType.FORM + result = await hass.config_entries.flow.async_configure( result["flow_id"], {"display_options": ["time"]}, ) - await hass.async_block_till_done() - assert result["type"] == FlowResultType.ABORT assert result["reason"] == "single_instance_allowed" @@ -140,3 +142,161 @@ async def test_options(hass: HomeAssistant) -> None: assert result["type"] == FlowResultType.CREATE_ENTRY assert result["data"] == {"display_options": ["time", "date", "beat"]} + + +async def test_config_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the config flow preview.""" + client = await hass_ws_client(hass) + freezer.move_to("2024-01-02 20:14:11.672") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] is None + assert result["preview"] == "time_date" + + await client.send_json_auto_id( + { + "type": "time_date/start_preview", + "flow_id": result["flow_id"], + "flow_type": "config_flow", + "user_input": {"display_options": ["time", "date"]}, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == { + "items": [ + { + "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, + "state": "12:14", + } + ] + } + msg = await client.receive_json() + assert msg["event"] == { + "items": [ + { + "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, + "state": "12:14", + }, + { + "attributes": {"friendly_name": "Date", "icon": "mdi:calendar"}, + "state": "2024-01-02", + }, + ] + } + + freezer.tick(60) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + msg = await client.receive_json() + assert msg["event"] == { + "items": [ + { + "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, + "state": "12:15", + }, + { + "attributes": {"friendly_name": "Date", "icon": "mdi:calendar"}, + "state": "2024-01-02", + }, + ] + } + assert len(hass.states.async_all()) == 0 + + +async def test_option_flow_preview( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + freezer: FrozenDateTimeFactory, +) -> None: + """Test the option flow preview.""" + client = await hass_ws_client(hass) + freezer.move_to("2024-01-02 20:14:11.672") + + config_entry = MockConfigEntry( + domain=DOMAIN, data={}, options={"display_options": ["time"]} + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "time_date" + + await client.send_json_auto_id( + { + "type": "time_date/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": {"display_options": ["time", "date"]}, + } + ) + msg = await client.receive_json() + assert msg["success"] + assert msg["result"] is None + + msg = await client.receive_json() + assert msg["event"] == { + "items": [ + { + "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, + "state": "12:14", + } + ] + } + msg = await client.receive_json() + assert msg["event"] == { + "items": [ + { + "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, + "state": "12:14", + }, + { + "attributes": {"friendly_name": "Date", "icon": "mdi:calendar"}, + "state": "2024-01-02", + }, + ] + } + + +async def test_option_flow_sensor_preview_config_entry_removed( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test the option flow preview where the config entry is removed.""" + client = await hass_ws_client(hass) + + config_entry = MockConfigEntry( + domain=DOMAIN, data={}, options={"display_options": ["time"]} + ) + config_entry.add_to_hass(hass) + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == FlowResultType.FORM + assert result["errors"] is None + assert result["preview"] == "time_date" + + await hass.config_entries.async_remove(config_entry.entry_id) + + await client.send_json_auto_id( + { + "type": "time_date/start_preview", + "flow_id": result["flow_id"], + "flow_type": "options_flow", + "user_input": {"display_options": ["time", "date"]}, + } + ) + msg = await client.receive_json() + assert not msg["success"] + assert msg["error"] == {"code": "home_assistant_error", "message": "Unknown error"} From b3714479cfef60947da0101e26e3bc3375836b47 Mon Sep 17 00:00:00 2001 From: Erik Date: Tue, 2 Jan 2024 22:02:59 +0100 Subject: [PATCH 09/20] Fix rebase mistake --- homeassistant/components/time_date/config_flow.py | 2 +- homeassistant/components/time_date/sensor.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/time_date/config_flow.py b/homeassistant/components/time_date/config_flow.py index dfe5a646e20bba..7409773e909ffb 100644 --- a/homeassistant/components/time_date/config_flow.py +++ b/homeassistant/components/time_date/config_flow.py @@ -159,7 +159,7 @@ def async_unsubscripe_subscriptions() -> None: subscriptions.pop()() preview_entities = { - option_type: TimeDateSensor(option_type, "preview") + option_type: TimeDateSensor(option_type) for option_type in validated[CONF_DISPLAY_OPTIONS] } diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index 2d58cfab1029c0..f8621649e31227 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -98,7 +98,7 @@ async def async_setup_entry( async_add_entities( [ - TimeDateSensor(option_type, entry.entry_id) + TimeDateSensor(option_type) for option_type in entry.options[CONF_DISPLAY_OPTIONS] ] ) @@ -112,7 +112,7 @@ class TimeDateSensor(SensorEntity): _state: str | None = None unsub: CALLBACK_TYPE | None = None - def __init__(self, hass: HomeAssistant, option_type: str) -> None: + def __init__(self, option_type: str) -> None: """Initialize the sensor.""" self._attr_translation_key = option_type self.type = option_type From 6c65b1e15fd0c9e7cfee249ef9cb466171e054c7 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 3 Jan 2024 19:13:37 +0000 Subject: [PATCH 10/20] coverage --- tests/components/time_date/test_config_flow.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/tests/components/time_date/test_config_flow.py b/tests/components/time_date/test_config_flow.py index b00896369e134a..cd24e3169429c6 100644 --- a/tests/components/time_date/test_config_flow.py +++ b/tests/components/time_date/test_config_flow.py @@ -8,9 +8,11 @@ import voluptuous as vol from homeassistant import config_entries +from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.time_date.const import CONF_DISPLAY_OPTIONS, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.entity_registry import EntityRegistry from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -220,6 +222,7 @@ async def test_option_flow_preview( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, + entity_registry: EntityRegistry, ) -> None: """Test the option flow preview.""" client = await hass_ws_client(hass) @@ -230,6 +233,10 @@ async def test_option_flow_preview( ) config_entry.add_to_hass(hass) + entity_registry.async_get_or_create( + DOMAIN, SENSOR_DOMAIN, "time", config_entry=config_entry + ) + result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] == FlowResultType.FORM assert result["errors"] is None From 4a75628e00bdecbd595d8260a8d266b587a1454c Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 6 Jan 2024 17:17:15 +0000 Subject: [PATCH 11/20] Change to one type per setup --- .../components/time_date/config_flow.py | 38 +++--------- homeassistant/components/time_date/sensor.py | 58 ++++++------------- .../components/time_date/strings.json | 3 +- 3 files changed, 26 insertions(+), 73 deletions(-) diff --git a/homeassistant/components/time_date/config_flow.py b/homeassistant/components/time_date/config_flow.py index 7409773e909ffb..df2efc879bbda6 100644 --- a/homeassistant/components/time_date/config_flow.py +++ b/homeassistant/components/time_date/config_flow.py @@ -8,10 +8,9 @@ import voluptuous as vol -from homeassistant import data_entry_flow from homeassistant.components import websocket_api from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, async_get_hass, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import EntityPlatform @@ -39,20 +38,6 @@ SelectSelectorConfig( options=[option for option in OPTION_TYPES if option != "beat"], mode=SelectSelectorMode.DROPDOWN, - multiple=True, - translation_key="display_options", - ) - ), - } -) - -IMPORT_OPTIONS_SCHEMA = vol.Schema( - { - vol.Required(CONF_DISPLAY_OPTIONS): SelectSelector( - SelectSelectorConfig( - options=OPTION_TYPES, - mode=SelectSelectorMode.DROPDOWN, - multiple=True, translation_key="display_options", ) ), @@ -64,7 +49,7 @@ async def validate_input( handler: SchemaCommonFlowHandler, user_input: dict[str, Any] ) -> dict[str, Any]: """Validate rest setup.""" - hass = async_get_hass() + hass = handler.parent_handler.hass if hass.config.time_zone is None: raise SchemaFlowError("timezone_not_exist") return user_input @@ -75,14 +60,7 @@ async def validate_input( schema=USER_SCHEMA, preview=DOMAIN, validate_user_input=validate_input, - ), - "import": SchemaFlowFormStep( - schema=IMPORT_OPTIONS_SCHEMA, - validate_user_input=validate_input, - ), -} -OPTIONS_FLOW = { - "init": SchemaFlowFormStep(schema=IMPORT_OPTIONS_SCHEMA, preview=DOMAIN) + ) } @@ -90,16 +68,14 @@ class TimeDateConfigFlowHandler(SchemaConfigFlowHandler, domain=DOMAIN): """Handle a config flow for Time & Date.""" config_flow = CONFIG_FLOW - options_flow = OPTIONS_FLOW def async_config_entry_title(self, options: Mapping[str, Any]) -> str: """Return config entry title.""" - return "Time & Date" + return f"Time & Date {options[CONF_DISPLAY_OPTIONS]}" def async_config_flow_finished(self, options: Mapping[str, Any]) -> None: """Abort if instance already exist.""" - if self._async_current_entries(): - raise data_entry_flow.AbortFlow("single_instance_allowed") + self._async_abort_entries_match(dict(options)) @staticmethod async def async_setup_preview(hass: HomeAssistant) -> None: @@ -111,7 +87,7 @@ async def async_setup_preview(hass: HomeAssistant) -> None: { vol.Required("type"): "time_date/start_preview", vol.Required("flow_id"): str, - vol.Required("flow_type"): vol.Any("config_flow", "options_flow"), + vol.Required("flow_type"): vol.Any("config_flow"), vol.Required("user_input"): dict, } ) @@ -122,7 +98,7 @@ async def ws_start_preview( msg: dict[str, Any], ) -> None: """Generate a preview.""" - validated = IMPORT_OPTIONS_SCHEMA(msg["user_input"]) + validated = USER_SCHEMA(msg["user_input"]) # Create an EntityPlatform, needed for name translations platform = await async_prepare_setup_platform(hass, {}, SENSOR_DOMAIN, DOMAIN) diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index f8621649e31227..f0cec6813d2a46 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -13,15 +13,9 @@ PLATFORM_SCHEMA, SensorEntity, ) -from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntry +from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISPLAY_OPTIONS, EVENT_CORE_CONFIG_UPDATE -from homeassistant.core import ( - CALLBACK_TYPE, - DOMAIN as HOMEASSISTANT_DOMAIN, - Event, - HomeAssistant, - callback, -) +from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback @@ -53,57 +47,39 @@ async def async_setup_platform( discovery_info: DiscoveryInfoType | None = None, ) -> None: """Set up the Time and Date sensor.""" - async_create_issue( - hass, - HOMEASSISTANT_DOMAIN, - f"deprecated_yaml_{DOMAIN}", - breaks_in_ha_version="2024.7.0", - is_fixable=False, - issue_domain=DOMAIN, - severity=IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "domain": DOMAIN, - "integration_title": "Time & Date", - }, - ) - 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 Time & Date sensor.""" - if "beat" in entry.options[CONF_DISPLAY_OPTIONS]: + if hass.config.time_zone is None: + _LOGGER.error("Timezone is not set in Home Assistant configuration") # type: ignore[unreachable] + return False + if "beat" in config[CONF_DISPLAY_OPTIONS]: async_create_issue( hass, DOMAIN, "deprecated_beat", breaks_in_ha_version="2024.7.0", - is_fixable=True, + is_fixable=False, severity=IssueSeverity.WARNING, translation_key="deprecated_beat", translation_placeholders={ "config_key": "beat", + "display_options": "display_options", "integration": DOMAIN, }, ) _LOGGER.warning("'beat': is deprecated and will be removed in version 2024.7") async_add_entities( - [ - TimeDateSensor(option_type) - for option_type in entry.options[CONF_DISPLAY_OPTIONS] - ] + [TimeDateSensor(variable) for variable in config[CONF_DISPLAY_OPTIONS]] ) +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up the Time & Date sensor.""" + + async_add_entities([TimeDateSensor(entry.options[CONF_DISPLAY_OPTIONS])]) + + class TimeDateSensor(SensorEntity): """Implementation of a Time and Date sensor.""" diff --git a/homeassistant/components/time_date/strings.json b/homeassistant/components/time_date/strings.json index 85ad05cb524583..6dd73b4c2f365b 100644 --- a/homeassistant/components/time_date/strings.json +++ b/homeassistant/components/time_date/strings.json @@ -1,10 +1,11 @@ { "config": { "abort": { - "single_instance_allowed": "[%key:common::config_flow::abort::single_instance_allowed%]" + "already_configured": "Time & Date option type has already been configured" }, "step": { "user": { + "description": "Select your type below", "data": { "display_options": "Sensors" } From 6e81610a2be9878f155c90ef872fd8a6223b687f Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 6 Jan 2024 17:17:37 +0000 Subject: [PATCH 12/20] Change to helper --- homeassistant/components/time_date/manifest.json | 1 + homeassistant/generated/config_flows.py | 2 +- homeassistant/generated/integrations.json | 12 ++++++------ 3 files changed, 8 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/time_date/manifest.json b/homeassistant/components/time_date/manifest.json index e756ec78f94c39..de50944fbc3738 100644 --- a/homeassistant/components/time_date/manifest.json +++ b/homeassistant/components/time_date/manifest.json @@ -4,6 +4,7 @@ "codeowners": ["@fabaff"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/time_date", + "integration_type": "helper", "iot_class": "local_push", "quality_scale": "internal" } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index fe5e746a8355b0..cede860f100f8d 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -13,6 +13,7 @@ "switch_as_x", "template", "threshold", + "time_date", "tod", "trend", "utility_meter", @@ -510,7 +511,6 @@ "tibber", "tile", "tilt_ble", - "time_date", "todoist", "tolo", "tomorrowio", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bce252e5bb83fd..e4262cf978eea7 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5974,12 +5974,6 @@ "config_flow": true, "iot_class": "local_push" }, - "time_date": { - "name": "Time & Date", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" - }, "tmb": { "name": "Transports Metropolitans de Barcelona", "integration_type": "hub", @@ -6934,6 +6928,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "time_date": { + "name": "Time & Date", + "integration_type": "helper", + "config_flow": true, + "iot_class": "local_push" + }, "timer": { "name": "Timer", "integration_type": "helper", From b27a60d27272adf69408dab78f5a93e952e61170 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Sat, 6 Jan 2024 17:45:33 +0000 Subject: [PATCH 13/20] Sensor --- homeassistant/components/time_date/sensor.py | 17 +++++++---------- 1 file changed, 7 insertions(+), 10 deletions(-) diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index f0cec6813d2a46..adf4fd32eb657a 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -17,7 +17,6 @@ from homeassistant.const import CONF_DISPLAY_OPTIONS, EVENT_CORE_CONFIG_UPDATE from homeassistant.core import CALLBACK_TYPE, Event, HomeAssistant, callback import homeassistant.helpers.config_validation as cv -from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_track_point_in_utc_time from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -50,6 +49,7 @@ async def async_setup_platform( if hass.config.time_zone is None: _LOGGER.error("Timezone is not set in Home Assistant configuration") # type: ignore[unreachable] return False + if "beat" in config[CONF_DISPLAY_OPTIONS]: async_create_issue( hass, @@ -77,7 +77,9 @@ async def async_setup_entry( ) -> None: """Set up the Time & Date sensor.""" - async_add_entities([TimeDateSensor(entry.options[CONF_DISPLAY_OPTIONS])]) + async_add_entities( + [TimeDateSensor(entry.options[CONF_DISPLAY_OPTIONS], entry.entry_id)] + ) class TimeDateSensor(SensorEntity): @@ -88,18 +90,13 @@ class TimeDateSensor(SensorEntity): _state: str | None = None unsub: CALLBACK_TYPE | None = None - def __init__(self, option_type: str) -> None: + def __init__(self, option_type: str, entry_id: str | None = None) -> None: """Initialize the sensor.""" - self._attr_translation_key = option_type + self._attr_translation_a_key = option_type self.type = option_type object_id = "internet_time" if option_type == "beat" else option_type self.entity_id = ENTITY_ID_FORMAT.format(object_id) - self._attr_unique_id = option_type - self._attr_device_info = DeviceInfo( - entry_type=DeviceEntryType.SERVICE, - identifiers={(DOMAIN, DOMAIN)}, - name="Time & Date", - ) + self._attr_unique_id = option_type if entry_id else None self._update_internal_state(dt_util.utcnow()) From 07d6d85c50b124d53996a2d6c860339c23bb42ac Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 19 Jan 2024 18:49:29 +0000 Subject: [PATCH 14/20] Preview --- .../components/time_date/config_flow.py | 32 ++++++------------- homeassistant/components/time_date/sensor.py | 8 ++--- 2 files changed, 12 insertions(+), 28 deletions(-) diff --git a/homeassistant/components/time_date/config_flow.py b/homeassistant/components/time_date/config_flow.py index df2efc879bbda6..2427ae6a2ff58a 100644 --- a/homeassistant/components/time_date/config_flow.py +++ b/homeassistant/components/time_date/config_flow.py @@ -113,17 +113,12 @@ async def ws_start_preview( ) await entity_platform.async_load_translations() - preview_states: dict[str, dict[str, str | Mapping[str, Any]]] = {} - @callback - def async_preview_updated( - key: str, state: str, attributes: Mapping[str, Any] - ) -> None: + def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: """Forward config entry state events to websocket.""" - preview_states[key] = {"attributes": attributes, "state": state} connection.send_message( websocket_api.event_message( - msg["id"], {"items": list(preview_states.values())} + msg["id"], {"attributes": attributes, "state": state} ) ) @@ -134,14 +129,9 @@ def async_unsubscripe_subscriptions() -> None: while subscriptions: subscriptions.pop()() - preview_entities = { - option_type: TimeDateSensor(option_type) - for option_type in validated[CONF_DISPLAY_OPTIONS] - } - - for preview_entity in preview_entities.values(): - preview_entity.hass = hass - preview_entity.platform = entity_platform + preview_entity = TimeDateSensor(validated[CONF_DISPLAY_OPTIONS]) + preview_entity.hass = hass + preview_entity.platform = entity_platform if msg["flow_type"] == "options_flow": flow_status = hass.config_entries.options.async_get(msg["flow_id"]) @@ -151,15 +141,11 @@ def async_unsubscripe_subscriptions() -> None: raise HomeAssistantError entity_registry = er.async_get(hass) entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) - for option_type, preview_entity in preview_entities.items(): - expected_unique_id = option_type - for entry in entries: - if entry.unique_id == expected_unique_id: - preview_entity.registry_entry = entry - break + preview_entity.registry_entry = entries[0] connection.send_result(msg["id"]) - for preview_entity in preview_entities.values(): - subscriptions.append(preview_entity.async_start_preview(async_preview_updated)) + connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( + async_preview_updated + ) connection.subscriptions[msg["id"]] = async_unsubscripe_subscriptions diff --git a/homeassistant/components/time_date/sensor.py b/homeassistant/components/time_date/sensor.py index adf4fd32eb657a..bd0f9449aea1af 100644 --- a/homeassistant/components/time_date/sensor.py +++ b/homeassistant/components/time_date/sensor.py @@ -92,7 +92,7 @@ class TimeDateSensor(SensorEntity): def __init__(self, option_type: str, entry_id: str | None = None) -> None: """Initialize the sensor.""" - self._attr_translation_a_key = option_type + self._attr_translation_key = option_type self.type = option_type object_id = "internet_time" if option_type == "beat" else option_type self.entity_id = ENTITY_ID_FORMAT.format(object_id) @@ -117,7 +117,7 @@ def icon(self) -> str: @callback def async_start_preview( self, - preview_callback: Callable[[str, str, Mapping[str, Any]], None], + preview_callback: Callable[[str, Mapping[str, Any]], None], ) -> CALLBACK_TYPE: """Render a preview.""" @@ -131,9 +131,7 @@ def point_in_time_listener(time_date: datetime | None) -> None: self.hass, point_in_time_listener, self.get_next_interval(now) ) calculated_state = self._async_calculate_state() - preview_callback( - self.type, calculated_state.state, calculated_state.attributes - ) + preview_callback(calculated_state.state, calculated_state.attributes) @callback def async_stop_preview() -> None: From 82424ceb032dcbb5705968f91aff6b786558ea32 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 19 Jan 2024 20:31:19 +0000 Subject: [PATCH 15/20] Fix tests --- tests/components/time_date/__init__.py | 12 +- .../components/time_date/test_config_flow.py | 193 +----------------- tests/components/time_date/test_init.py | 17 -- tests/components/time_date/test_sensor.py | 26 ++- 4 files changed, 36 insertions(+), 212 deletions(-) diff --git a/tests/components/time_date/__init__.py b/tests/components/time_date/__init__.py index 90c9a799b8f965..9817271a8d97f8 100644 --- a/tests/components/time_date/__init__.py +++ b/tests/components/time_date/__init__.py @@ -1,6 +1,6 @@ """Tests for the time_date component.""" -from homeassistant.components.time_date.const import DOMAIN, OPTION_TYPES +from homeassistant.components.time_date.const import DOMAIN from homeassistant.config_entries import SOURCE_USER from homeassistant.const import CONF_DISPLAY_OPTIONS from homeassistant.core import HomeAssistant @@ -9,17 +9,17 @@ async def load_int( - hass: HomeAssistant, display_options: list[str] | None = None + hass: HomeAssistant, display_option: str | None = None ) -> MockConfigEntry: """Set up the Time & Date integration in Home Assistant.""" - if display_options is None: - display_options = OPTION_TYPES + if display_option is None: + display_option = "time" config_entry = MockConfigEntry( domain=DOMAIN, source=SOURCE_USER, data={}, - options={CONF_DISPLAY_OPTIONS: display_options}, - entry_id="1234567890", + options={CONF_DISPLAY_OPTIONS: display_option}, + entry_id=f"1234567890_{display_option}", ) config_entry.add_to_hass(hass) diff --git a/tests/components/time_date/test_config_flow.py b/tests/components/time_date/test_config_flow.py index cd24e3169429c6..228a34b65b4957 100644 --- a/tests/components/time_date/test_config_flow.py +++ b/tests/components/time_date/test_config_flow.py @@ -8,11 +8,9 @@ import voluptuous as vol from homeassistant import config_entries -from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.time_date.const import CONF_DISPLAY_OPTIONS, DOMAIN from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType -from homeassistant.helpers.entity_registry import EntityRegistry from tests.common import MockConfigEntry, async_fire_time_changed from tests.typing import WebSocketGenerator @@ -30,7 +28,7 @@ async def test_form(hass: HomeAssistant, mock_setup_entry: AsyncMock) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"display_options": ["time"]}, + {"display_options": "time"}, ) await hass.async_block_till_done() @@ -51,7 +49,7 @@ async def test_user_flow_does_not_allow_beat( with pytest.raises(vol.Invalid): await hass.config_entries.flow.async_configure( result["flow_id"], - {"display_options": ["time", "beat"]}, + {"display_options": ["beat"]}, ) @@ -59,7 +57,7 @@ async def test_single_instance(hass: HomeAssistant) -> None: """Test we get the forms.""" entry = MockConfigEntry( - domain=DOMAIN, data={}, options={CONF_DISPLAY_OPTIONS: ["time", "date"]} + domain=DOMAIN, data={}, options={CONF_DISPLAY_OPTIONS: "time"} ) entry.add_to_hass(hass) @@ -70,44 +68,10 @@ async def test_single_instance(hass: HomeAssistant) -> None: result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"display_options": ["time"]}, + {"display_options": "time"}, ) assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" - - -async def test_import_flow_success(hass: HomeAssistant) -> None: - """Test a successful import of yaml.""" - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_DISPLAY_OPTIONS: ["time", "date", "beat"]}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["title"] == "Time & Date" - assert result["options"] == {"display_options": ["time", "date", "beat"]} - - -async def test_import_flow_already_exist(hass: HomeAssistant) -> None: - """Test import of yaml already exist.""" - - entry = MockConfigEntry( - domain=DOMAIN, data={}, options={CONF_DISPLAY_OPTIONS: ["time", "date"]} - ) - entry.add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data={CONF_DISPLAY_OPTIONS: ["time", "date"]}, - ) - await hass.async_block_till_done() - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "already_configured" async def test_timezone_not_set(hass: HomeAssistant) -> None: @@ -119,7 +83,7 @@ async def test_timezone_not_set(hass: HomeAssistant) -> None: ) result = await hass.config_entries.flow.async_configure( result["flow_id"], - {"display_options": ["time"]}, + {"display_options": "time"}, ) await hass.async_block_till_done() @@ -127,25 +91,6 @@ async def test_timezone_not_set(hass: HomeAssistant) -> None: assert result["errors"] == {"base": "timezone_not_exist"} -async def test_options(hass: HomeAssistant) -> None: - """Test updating options.""" - entry = MockConfigEntry(domain=DOMAIN, data={"display_options": ["time"]}) - entry.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(entry.entry_id) - - assert result["type"] == FlowResultType.FORM - assert result["step_id"] == "init" - - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"display_options": ["time", "date", "beat"]}, - ) - - assert result["type"] == FlowResultType.CREATE_ENTRY - assert result["data"] == {"display_options": ["time", "date", "beat"]} - - async def test_config_flow_preview( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, @@ -168,7 +113,7 @@ async def test_config_flow_preview( "type": "time_date/start_preview", "flow_id": result["flow_id"], "flow_type": "config_flow", - "user_input": {"display_options": ["time", "date"]}, + "user_input": {"display_options": "time"}, } ) msg = await client.receive_json() @@ -177,25 +122,8 @@ async def test_config_flow_preview( msg = await client.receive_json() assert msg["event"] == { - "items": [ - { - "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, - "state": "12:14", - } - ] - } - msg = await client.receive_json() - assert msg["event"] == { - "items": [ - { - "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, - "state": "12:14", - }, - { - "attributes": {"friendly_name": "Date", "icon": "mdi:calendar"}, - "state": "2024-01-02", - }, - ] + "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, + "state": "12:14", } freezer.tick(60) @@ -204,106 +132,7 @@ async def test_config_flow_preview( msg = await client.receive_json() assert msg["event"] == { - "items": [ - { - "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, - "state": "12:15", - }, - { - "attributes": {"friendly_name": "Date", "icon": "mdi:calendar"}, - "state": "2024-01-02", - }, - ] + "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, + "state": "12:15", } assert len(hass.states.async_all()) == 0 - - -async def test_option_flow_preview( - hass: HomeAssistant, - hass_ws_client: WebSocketGenerator, - freezer: FrozenDateTimeFactory, - entity_registry: EntityRegistry, -) -> None: - """Test the option flow preview.""" - client = await hass_ws_client(hass) - freezer.move_to("2024-01-02 20:14:11.672") - - config_entry = MockConfigEntry( - domain=DOMAIN, data={}, options={"display_options": ["time"]} - ) - config_entry.add_to_hass(hass) - - entity_registry.async_get_or_create( - DOMAIN, SENSOR_DOMAIN, "time", config_entry=config_entry - ) - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - assert result["preview"] == "time_date" - - await client.send_json_auto_id( - { - "type": "time_date/start_preview", - "flow_id": result["flow_id"], - "flow_type": "options_flow", - "user_input": {"display_options": ["time", "date"]}, - } - ) - msg = await client.receive_json() - assert msg["success"] - assert msg["result"] is None - - msg = await client.receive_json() - assert msg["event"] == { - "items": [ - { - "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, - "state": "12:14", - } - ] - } - msg = await client.receive_json() - assert msg["event"] == { - "items": [ - { - "attributes": {"friendly_name": "Time", "icon": "mdi:clock"}, - "state": "12:14", - }, - { - "attributes": {"friendly_name": "Date", "icon": "mdi:calendar"}, - "state": "2024-01-02", - }, - ] - } - - -async def test_option_flow_sensor_preview_config_entry_removed( - hass: HomeAssistant, hass_ws_client: WebSocketGenerator -) -> None: - """Test the option flow preview where the config entry is removed.""" - client = await hass_ws_client(hass) - - config_entry = MockConfigEntry( - domain=DOMAIN, data={}, options={"display_options": ["time"]} - ) - config_entry.add_to_hass(hass) - - result = await hass.config_entries.options.async_init(config_entry.entry_id) - assert result["type"] == FlowResultType.FORM - assert result["errors"] is None - assert result["preview"] == "time_date" - - await hass.config_entries.async_remove(config_entry.entry_id) - - await client.send_json_auto_id( - { - "type": "time_date/start_preview", - "flow_id": result["flow_id"], - "flow_type": "options_flow", - "user_input": {"display_options": ["time", "date"]}, - } - ) - msg = await client.receive_json() - assert not msg["success"] - assert msg["error"] == {"code": "home_assistant_error", "message": "Unknown error"} diff --git a/tests/components/time_date/test_init.py b/tests/components/time_date/test_init.py index 68428209e52817..cd7c5044201197 100644 --- a/tests/components/time_date/test_init.py +++ b/tests/components/time_date/test_init.py @@ -16,20 +16,3 @@ async def test_setup_and_remove_config_entry(hass: HomeAssistant) -> None: await hass.async_block_till_done() assert hass.states.get("sensor.time") is None - - -async def test_reload_config_entry(hass: HomeAssistant) -> None: - """Test setting up and removing a config entry.""" - entry = await load_int(hass) - - state = hass.states.get("sensor.time") - assert state is not None - - result = await hass.config_entries.options.async_init(entry.entry_id) - result = await hass.config_entries.options.async_configure( - result["flow_id"], - user_input={"display_options": ["date"]}, - ) - await hass.async_block_till_done() - - assert hass.states.get("sensor.time") is None diff --git a/tests/components/time_date/test_sensor.py b/tests/components/time_date/test_sensor.py index 7d8bc37fdb4909..d7e87b3a471971 100644 --- a/tests/components/time_date/test_sensor.py +++ b/tests/components/time_date/test_sensor.py @@ -5,9 +5,10 @@ from freezegun.api import FrozenDateTimeFactory import pytest -from homeassistant.components.time_date.const import DOMAIN +from homeassistant.components.time_date.const import DOMAIN, OPTION_TYPES from homeassistant.core import HomeAssistant from homeassistant.helpers import event, issue_registry as ir +from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from . import load_int @@ -53,7 +54,7 @@ async def test_intervals( hass.config.set_time_zone("UTC") freezer.move_to(start_time) - await load_int(hass, [display_option]) + await load_int(hass, display_option) mock_track_interval.assert_called_once_with(hass, ANY, tracked_time) @@ -64,7 +65,8 @@ async def test_states(hass: HomeAssistant, freezer: FrozenDateTimeFactory) -> No now = dt_util.utc_from_timestamp(1495068856) freezer.move_to(now) - await load_int(hass) + for option in OPTION_TYPES: + await load_int(hass, option) state = hass.states.get("sensor.time") assert state.state == "00:54" @@ -123,7 +125,8 @@ async def test_states_non_default_timezone( now = dt_util.utc_from_timestamp(1495068856) freezer.move_to(now) - await load_int(hass) + for option in OPTION_TYPES: + await load_int(hass, option) state = hass.states.get("sensor.time") assert state.state == "20:54" @@ -254,7 +257,7 @@ async def test_timezone_intervals( hass.config.set_time_zone(time_zone) freezer.move_to(start_time) - await load_int(hass, ["date"]) + await load_int(hass, "date") mock_track_interval.assert_called_once() next_time = mock_track_interval.mock_calls[0][1][2] @@ -264,7 +267,8 @@ async def test_timezone_intervals( async def test_icons(hass: HomeAssistant) -> None: """Test attributes of sensors.""" - await load_int(hass) + for option in OPTION_TYPES: + await load_int(hass, option) state = hass.states.get("sensor.time") assert state.attributes["icon"] == "mdi:clock" @@ -302,7 +306,15 @@ async def test_deprecation_warning( expected_issues: list[str], ) -> None: """Test deprecation warning for swatch beat.""" - await load_int(hass, display_options) + config = { + "sensor": { + "platform": "time_date", + "display_options": display_options, + } + } + + assert await async_setup_component(hass, "sensor", config) + await hass.async_block_till_done() warnings = [record for record in caplog.records if record.levelname == "WARNING"] assert len(warnings) == len(expected_warnings) From 33392ab566e02e640977cd69a7db073c68f77959 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 19 Jan 2024 20:31:42 +0000 Subject: [PATCH 16/20] Cleanup not needed --- homeassistant/components/time_date/__init__.py | 17 ----------------- .../components/time_date/config_flow.py | 12 ------------ 2 files changed, 29 deletions(-) diff --git a/homeassistant/components/time_date/__init__.py b/homeassistant/components/time_date/__init__.py index 76c3007d60e80f..cdd69a2bc1fcff 100644 --- a/homeassistant/components/time_date/__init__.py +++ b/homeassistant/components/time_date/__init__.py @@ -3,33 +3,16 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er from .const import PLATFORMS async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Time & Date from a config entry.""" - remove_unused_entities(hass, entry) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) - entry.async_on_unload(entry.add_update_listener(async_update_listener)) return True async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Unload Time & Date config entry.""" return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) - - -async def async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Update listener for options.""" - await hass.config_entries.async_reload(entry.entry_id) - - -def remove_unused_entities(hass: HomeAssistant, entry: ConfigEntry) -> None: - """Cleanup entities not selected.""" - entity_reg = er.async_get(hass) - entities = er.async_entries_for_config_entry(entity_reg, entry.entry_id) - for entity in entities: - if entity.unique_id not in entry.options["display_options"]: - entity_reg.async_remove(entity.entity_id) diff --git a/homeassistant/components/time_date/config_flow.py b/homeassistant/components/time_date/config_flow.py index 2427ae6a2ff58a..0471db7da89388 100644 --- a/homeassistant/components/time_date/config_flow.py +++ b/homeassistant/components/time_date/config_flow.py @@ -11,8 +11,6 @@ from homeassistant.components import websocket_api from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback -from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, @@ -133,16 +131,6 @@ def async_unsubscripe_subscriptions() -> None: preview_entity.hass = hass preview_entity.platform = entity_platform - if msg["flow_type"] == "options_flow": - flow_status = hass.config_entries.options.async_get(msg["flow_id"]) - config_entry_id = flow_status["handler"] - config_entry = hass.config_entries.async_get_entry(config_entry_id) - if not config_entry: - raise HomeAssistantError - entity_registry = er.async_get(hass) - entries = er.async_entries_for_config_entry(entity_registry, config_entry_id) - preview_entity.registry_entry = entries[0] - connection.send_result(msg["id"]) connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( async_preview_updated From 0ddce7c2784bd9fae26d2a75eb0a8a60df2ab757 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Fri, 19 Jan 2024 22:38:01 +0000 Subject: [PATCH 17/20] clean --- homeassistant/components/time_date/config_flow.py | 11 +---------- 1 file changed, 1 insertion(+), 10 deletions(-) diff --git a/homeassistant/components/time_date/config_flow.py b/homeassistant/components/time_date/config_flow.py index 0471db7da89388..09a5f2503d08bc 100644 --- a/homeassistant/components/time_date/config_flow.py +++ b/homeassistant/components/time_date/config_flow.py @@ -10,7 +10,7 @@ from homeassistant.components import websocket_api from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN -from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import EntityPlatform from homeassistant.helpers.schema_config_entry_flow import ( SchemaCommonFlowHandler, @@ -120,13 +120,6 @@ def async_preview_updated(state: str, attributes: Mapping[str, Any]) -> None: ) ) - subscriptions: list[CALLBACK_TYPE] = [] - - @callback - def async_unsubscripe_subscriptions() -> None: - while subscriptions: - subscriptions.pop()() - preview_entity = TimeDateSensor(validated[CONF_DISPLAY_OPTIONS]) preview_entity.hass = hass preview_entity.platform = entity_platform @@ -135,5 +128,3 @@ def async_unsubscripe_subscriptions() -> None: connection.subscriptions[msg["id"]] = preview_entity.async_start_preview( async_preview_updated ) - - connection.subscriptions[msg["id"]] = async_unsubscripe_subscriptions From ca1ee789eb5e87668b6cbaee5493bb99b2363ad3 Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 22 Jan 2024 18:02:25 +0000 Subject: [PATCH 18/20] Change to integrtion type service --- homeassistant/components/time_date/manifest.json | 2 +- homeassistant/generated/config_flows.py | 2 +- homeassistant/generated/integrations.json | 12 ++++++------ 3 files changed, 8 insertions(+), 8 deletions(-) diff --git a/homeassistant/components/time_date/manifest.json b/homeassistant/components/time_date/manifest.json index de50944fbc3738..9247b60568aa1e 100644 --- a/homeassistant/components/time_date/manifest.json +++ b/homeassistant/components/time_date/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@fabaff"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/time_date", - "integration_type": "helper", + "integration_type": "service", "iot_class": "local_push", "quality_scale": "internal" } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index cede860f100f8d..fe5e746a8355b0 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -13,7 +13,6 @@ "switch_as_x", "template", "threshold", - "time_date", "tod", "trend", "utility_meter", @@ -511,6 +510,7 @@ "tibber", "tile", "tilt_ble", + "time_date", "todoist", "tolo", "tomorrowio", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index e4262cf978eea7..d69b0a5c8bc839 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5974,6 +5974,12 @@ "config_flow": true, "iot_class": "local_push" }, + "time_date": { + "name": "Time & Date", + "integration_type": "service", + "config_flow": true, + "iot_class": "local_push" + }, "tmb": { "name": "Transports Metropolitans de Barcelona", "integration_type": "hub", @@ -6928,12 +6934,6 @@ "config_flow": true, "iot_class": "local_polling" }, - "time_date": { - "name": "Time & Date", - "integration_type": "helper", - "config_flow": true, - "iot_class": "local_push" - }, "timer": { "name": "Timer", "integration_type": "helper", From d4664306dcaa69b2a20bedbed9191b422b3da30a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 22 Jan 2024 19:02:43 +0000 Subject: [PATCH 19/20] Add translatable title --- homeassistant/components/time_date/strings.json | 1 + homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/homeassistant/components/time_date/strings.json b/homeassistant/components/time_date/strings.json index 6dd73b4c2f365b..963bb5cc9b95e5 100644 --- a/homeassistant/components/time_date/strings.json +++ b/homeassistant/components/time_date/strings.json @@ -1,4 +1,5 @@ { + "title": "Time & Date", "config": { "abort": { "already_configured": "Time & Date option type has already been configured" diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index d69b0a5c8bc839..58bafa60691741 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5975,7 +5975,6 @@ "iot_class": "local_push" }, "time_date": { - "name": "Time & Date", "integration_type": "service", "config_flow": true, "iot_class": "local_push" @@ -6999,6 +6998,7 @@ "switch_as_x", "tag", "threshold", + "time_date", "tod", "uptime", "utility_meter", From 20e27a3d81912c71d3f6f9679e4c46cb015bad1a Mon Sep 17 00:00:00 2001 From: G Johansson Date: Mon, 22 Jan 2024 19:16:42 +0000 Subject: [PATCH 20/20] Fix strings --- homeassistant/components/time_date/strings.json | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/time_date/strings.json b/homeassistant/components/time_date/strings.json index 963bb5cc9b95e5..e9efe949b9ba43 100644 --- a/homeassistant/components/time_date/strings.json +++ b/homeassistant/components/time_date/strings.json @@ -2,13 +2,13 @@ "title": "Time & Date", "config": { "abort": { - "already_configured": "Time & Date option type has already been configured" + "already_configured": "The chosen Time & Date sensor has already been configured" }, "step": { "user": { - "description": "Select your type below", + "description": "Select from the sensor options below", "data": { - "display_options": "Sensors" + "display_options": "Sensor type" } } },