diff --git a/homeassistant/components/tod/__init__.py b/homeassistant/components/tod/__init__.py index fa15326becb..09038836e2e 100644 --- a/homeassistant/components/tod/__init__.py +++ b/homeassistant/components/tod/__init__.py @@ -1 +1,27 @@ -"""The tod component.""" +"""The Times of the Day integration.""" +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Set up Times of the Day from a config entry.""" + hass.config_entries.async_setup_platforms(entry, (Platform.BINARY_SENSOR,)) + + entry.async_on_unload(entry.add_update_listener(config_entry_update_listener)) + + return True + + +async def config_entry_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None: + """Update listener, called when the config entry options are changed.""" + await hass.config_entries.async_reload(entry.entry_id) + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms( + entry, (Platform.BINARY_SENSOR,) + ) diff --git a/homeassistant/components/tod/binary_sensor.py b/homeassistant/components/tod/binary_sensor.py index 6f14d5735fb..1100892b876 100644 --- a/homeassistant/components/tod/binary_sensor.py +++ b/homeassistant/components/tod/binary_sensor.py @@ -8,6 +8,7 @@ import logging import voluptuous as vol from homeassistant.components.binary_sensor import PLATFORM_SCHEMA, BinarySensorEntity +from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( CONF_AFTER, CONF_BEFORE, @@ -22,15 +23,19 @@ from homeassistant.helpers.sun import get_astral_event_date, get_astral_event_ne from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from homeassistant.util import dt as dt_util +from .const import ( + CONF_AFTER_OFFSET, + CONF_AFTER_TIME, + CONF_BEFORE_OFFSET, + CONF_BEFORE_TIME, +) + _LOGGER = logging.getLogger(__name__) ATTR_AFTER = "after" ATTR_BEFORE = "before" ATTR_NEXT_UPDATE = "next_update" -CONF_AFTER_OFFSET = "after_offset" -CONF_BEFORE_OFFSET = "before_offset" - PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( { vol.Required(CONF_AFTER): vol.Any(cv.time, vol.All(vol.Lower, cv.sun_event)), @@ -42,6 +47,28 @@ PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( ) +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Initialize Times of the Day config entry.""" + if hass.config.time_zone is None: + _LOGGER.error("Timezone is not set in Home Assistant configuration") + return + + after = cv.time(config_entry.options[CONF_AFTER_TIME]) + after_offset = timedelta(0) + before = cv.time(config_entry.options[CONF_BEFORE_TIME]) + before_offset = timedelta(0) + name = config_entry.title + unique_id = config_entry.entry_id + + async_add_entities( + [TodSensor(name, after, after_offset, before, before_offset, unique_id)] + ) + + async def async_setup_platform( hass: HomeAssistant, config: ConfigType, @@ -58,12 +85,12 @@ async def async_setup_platform( before = config[CONF_BEFORE] before_offset = config[CONF_BEFORE_OFFSET] name = config[CONF_NAME] - sensor = TodSensor(name, after, after_offset, before, before_offset) + sensor = TodSensor(name, after, after_offset, before, before_offset, None) async_add_entities([sensor]) -def is_sun_event(sun_event): +def _is_sun_event(sun_event): """Return true if event is sun event not time.""" return sun_event in (SUN_EVENT_SUNRISE, SUN_EVENT_SUNSET) @@ -71,8 +98,9 @@ def is_sun_event(sun_event): class TodSensor(BinarySensorEntity): """Time of the Day Sensor.""" - def __init__(self, name, after, after_offset, before, before_offset): + def __init__(self, name, after, after_offset, before, before_offset, unique_id): """Init the ToD Sensor...""" + self._attr_unique_id = unique_id self._name = name self._time_before = self._time_after = self._next_update = None self._after_offset = after_offset @@ -119,11 +147,11 @@ class TodSensor(BinarySensorEntity): # calculate utc datetime corresponding to local time return dt_util.as_utc(datetime.combine(current_local_date, naive_time)) - def _calculate_boudary_time(self): + def _calculate_boundary_time(self): """Calculate internal absolute time boundaries.""" nowutc = dt_util.utcnow() # If after value is a sun event instead of absolute time - if is_sun_event(self._after): + if _is_sun_event(self._after): # Calculate the today's event utc time or # if not available take next after_event_date = get_astral_event_date( @@ -139,7 +167,7 @@ class TodSensor(BinarySensorEntity): self._time_after = after_event_date # If before value is a sun event instead of absolute time - if is_sun_event(self._before): + if _is_sun_event(self._before): # Calculate the today's event utc time or if not available take # next before_event_date = get_astral_event_date( @@ -168,7 +196,7 @@ class TodSensor(BinarySensorEntity): # _time_after is set to 23:00 today # nowutc is set to 10:00 today if ( - not is_sun_event(self._after) + not _is_sun_event(self._after) and self._time_after > nowutc and self._time_before > nowutc + timedelta(days=1) ): @@ -182,7 +210,7 @@ class TodSensor(BinarySensorEntity): def _turn_to_next_day(self): """Turn to to the next day.""" - if is_sun_event(self._after): + if _is_sun_event(self._after): self._time_after = get_astral_event_next( self.hass, self._after, self._time_after - self._after_offset ) @@ -191,7 +219,7 @@ class TodSensor(BinarySensorEntity): # Offset is already there self._time_after += timedelta(days=1) - if is_sun_event(self._before): + if _is_sun_event(self._before): self._time_before = get_astral_event_next( self.hass, self._before, self._time_before - self._before_offset ) @@ -202,7 +230,7 @@ class TodSensor(BinarySensorEntity): async def async_added_to_hass(self): """Call when entity about to be added to Home Assistant.""" - self._calculate_boudary_time() + self._calculate_boundary_time() self._calculate_next_update() @callback diff --git a/homeassistant/components/tod/config_flow.py b/homeassistant/components/tod/config_flow.py new file mode 100644 index 00000000000..ad0d70b2a0c --- /dev/null +++ b/homeassistant/components/tod/config_flow.py @@ -0,0 +1,49 @@ +"""Config flow for Times of the Day integration.""" +from __future__ import annotations + +from collections.abc import Mapping +from typing import Any, cast + +import voluptuous as vol + +from homeassistant.const import CONF_NAME +from homeassistant.helpers import selector +from homeassistant.helpers.helper_config_entry_flow import ( + HelperConfigFlowHandler, + HelperFlowFormStep, + HelperFlowMenuStep, +) + +from .const import CONF_AFTER_TIME, CONF_BEFORE_TIME, DOMAIN + +OPTIONS_SCHEMA = vol.Schema( + { + vol.Optional(CONF_AFTER_TIME): selector.selector({"time": {}}), + vol.Optional(CONF_BEFORE_TIME): selector.selector({"time": {}}), + } +) + +CONFIG_SCHEMA = vol.Schema( + { + vol.Required(CONF_NAME): selector.selector({"text": {}}), + } +).extend(OPTIONS_SCHEMA.schema) + +CONFIG_FLOW: dict[str, HelperFlowFormStep | HelperFlowMenuStep] = { + "user": HelperFlowFormStep(CONFIG_SCHEMA) +} + +OPTIONS_FLOW: dict[str, HelperFlowFormStep | HelperFlowMenuStep] = { + "init": HelperFlowFormStep(OPTIONS_SCHEMA) +} + + +class ConfigFlowHandler(HelperConfigFlowHandler, domain=DOMAIN): + """Handle a config or options flow for Times of the Day.""" + + config_flow = CONFIG_FLOW + options_flow = OPTIONS_FLOW + + def async_config_entry_title(self, options: Mapping[str, Any]) -> str: + """Return config entry title.""" + return cast(str, options["name"]) diff --git a/homeassistant/components/tod/const.py b/homeassistant/components/tod/const.py new file mode 100644 index 00000000000..3b6f8c23e17 --- /dev/null +++ b/homeassistant/components/tod/const.py @@ -0,0 +1,8 @@ +"""Constants for the Times of the Day integration.""" + +DOMAIN = "tod" + +CONF_AFTER_TIME = "after_time" +CONF_AFTER_OFFSET = "after_offset" +CONF_BEFORE_TIME = "before_time" +CONF_BEFORE_OFFSET = "before_offset" diff --git a/homeassistant/components/tod/manifest.json b/homeassistant/components/tod/manifest.json index b74465e05c3..8d3c0d4eab4 100644 --- a/homeassistant/components/tod/manifest.json +++ b/homeassistant/components/tod/manifest.json @@ -1,8 +1,10 @@ { "domain": "tod", + "integration_type": "helper", "name": "Times of the Day", "documentation": "https://www.home-assistant.io/integrations/tod", "codeowners": [], "quality_scale": "internal", - "iot_class": "local_push" + "iot_class": "local_push", + "config_flow": true } diff --git a/homeassistant/components/tod/strings.json b/homeassistant/components/tod/strings.json new file mode 100644 index 00000000000..713ff6e58e3 --- /dev/null +++ b/homeassistant/components/tod/strings.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "user": { + "title": "New Times of the Day Sensor", + "description": "Configure when the sensor should turn on and off.", + "data": { + "after": "On after", + "after_time": "On time", + "before": "Off after", + "before_time": "Off time", + "name": "Name" + } + } + } + }, + "options": { + "step": { + "init": { + "description": "[%key:component::tod::config::step::user::description%]", + "data": { + "after": "[%key:component::tod::config::step::user::data::after%]", + "after_time": "[%key:component::tod::config::step::user::data::after_time%]", + "before": "[%key:component::tod::config::step::user::data::before%]", + "before_time": "[%key:component::tod::config::step::user::data::before_time%]" + } + } + } + } +} diff --git a/homeassistant/components/tod/translations/en.json b/homeassistant/components/tod/translations/en.json new file mode 100644 index 00000000000..288b51c4b5e --- /dev/null +++ b/homeassistant/components/tod/translations/en.json @@ -0,0 +1,30 @@ +{ + "config": { + "step": { + "user": { + "data": { + "after": "On after", + "after_time": "On time", + "before": "Off after", + "before_time": "Off time", + "name": "Name" + }, + "description": "Configure when the sensor should turn on and off.", + "title": "New Times of the Day Sensor" + } + } + }, + "options": { + "step": { + "init": { + "data": { + "after": "On after", + "after_time": "On time", + "before": "Off after", + "before_time": "Off time" + }, + "description": "Configure when the sensor should turn on and off." + } + } + } +} \ No newline at end of file diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e1b6e95800d..f16f69cbede 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -400,6 +400,7 @@ FLOWS = { "zwave_me" ], "helper": [ - "derivative" + "derivative", + "tod" ] } diff --git a/tests/components/tod/test_config_flow.py b/tests/components/tod/test_config_flow.py new file mode 100644 index 00000000000..35f9ef0d5bd --- /dev/null +++ b/tests/components/tod/test_config_flow.py @@ -0,0 +1,125 @@ +"""Test the Times of the Day config flow.""" +from unittest.mock import patch + +from freezegun import freeze_time +import pytest + +from homeassistant import config_entries +from homeassistant.components.tod.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import RESULT_TYPE_CREATE_ENTRY, RESULT_TYPE_FORM + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize("platform", ("sensor",)) +async def test_config_flow(hass: HomeAssistant, platform) -> None: + """Test the config flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == RESULT_TYPE_FORM + assert result["errors"] is None + + with patch( + "homeassistant.components.tod.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "after_time": "10:00", + "before_time": "18:00", + "name": "My tod", + }, + ) + await hass.async_block_till_done() + + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["title"] == "My tod" + assert result["data"] == {} + assert result["options"] == { + "after_time": "10:00", + "before_time": "18:00", + "name": "My tod", + } + assert len(mock_setup_entry.mock_calls) == 1 + + config_entry = hass.config_entries.async_entries(DOMAIN)[0] + assert config_entry.data == {} + assert config_entry.options == { + "after_time": "10:00", + "before_time": "18:00", + "name": "My tod", + } + assert config_entry.title == "My tod" + + +def get_suggested(schema, key): + """Get suggested value for key in voluptuous schema.""" + for k in schema.keys(): + if k == key: + if k.description is None or "suggested_value" not in k.description: + return None + return k.description["suggested_value"] + # Wanted key absent from schema + raise Exception + + +@freeze_time("2022-03-16 17:37:00", tz_offset=-7) +async def test_options(hass: HomeAssistant) -> None: + """Test reconfiguring.""" + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "after_time": "10:00", + "before_time": "18:05", + "name": "My tod", + }, + title="My tod", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + result = await hass.config_entries.options.async_init(config_entry.entry_id) + assert result["type"] == RESULT_TYPE_FORM + assert result["step_id"] == "init" + schema = result["data_schema"].schema + assert get_suggested(schema, "after_time") == "10:00" + assert get_suggested(schema, "before_time") == "18:05" + + result = await hass.config_entries.options.async_configure( + result["flow_id"], + user_input={ + "after_time": "10:00", + "before_time": "17:05", + }, + ) + assert result["type"] == RESULT_TYPE_CREATE_ENTRY + assert result["data"] == { + "after_time": "10:00", + "before_time": "17:05", + "name": "My tod", + } + assert config_entry.data == {} + assert config_entry.options == { + "after_time": "10:00", + "before_time": "17:05", + "name": "My tod", + } + assert config_entry.title == "My tod" + + # Check config entry is reloaded with new options + await hass.async_block_till_done() + + # Check the entity was updated, no new entity was created + assert len(hass.states.async_all()) == 1 + + # Check the state of the entity has changed as expected + state = hass.states.get("binary_sensor.my_tod") + assert state.state == "off" + assert state.attributes["after"] == "2022-03-16T10:00:00-07:00" + assert state.attributes["before"] == "2022-03-16T17:05:00-07:00" diff --git a/tests/components/tod/test_init.py b/tests/components/tod/test_init.py new file mode 100644 index 00000000000..510bf848ad4 --- /dev/null +++ b/tests/components/tod/test_init.py @@ -0,0 +1,49 @@ +"""Test the Times of the Day integration.""" +from freezegun import freeze_time + +from homeassistant.components.tod.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry + + +@freeze_time("2022-03-16 17:37:00", tz_offset=-7) +async def test_setup_and_remove_config_entry(hass: HomeAssistant) -> None: + """Test setting up and removing a config entry.""" + registry = er.async_get(hass) + tod_entity_id = "binary_sensor.my_tod" + + # Setup the config entry + config_entry = MockConfigEntry( + data={}, + domain=DOMAIN, + options={ + "after_time": "10:00:00", + "before_time": "18:05:00", + "name": "My tod", + }, + title="My tod", + ) + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the entity is registered in the entity registry + assert registry.async_get(tod_entity_id) is not None + + # Check the platform is setup correctly + state = hass.states.get(tod_entity_id) + # Check the state of the entity is as expected + state = hass.states.get("binary_sensor.my_tod") + assert state.state == "off" + assert state.attributes["after"] == "2022-03-16T10:00:00-07:00" + assert state.attributes["before"] == "2022-03-16T18:05:00-07:00" + + # Remove the config entry + assert await hass.config_entries.async_remove(config_entry.entry_id) + await hass.async_block_till_done() + + # Check the state and entity registry entry are removed + assert hass.states.get(tod_entity_id) is None + assert registry.async_get(tod_entity_id) is None