From 8657bfd0bf27d14bd27fb4a0ddc1e8080c0d71ee Mon Sep 17 00:00:00 2001 From: Wendelin Date: Wed, 8 Oct 2025 17:08:48 +0200 Subject: [PATCH] Add input helper weekdays --- CODEOWNERS | 2 + .../components/input_weekday/__init__.py | 284 ++++++++++ .../components/input_weekday/icons.json | 29 + .../components/input_weekday/manifest.json | 8 + .../input_weekday/reproduce_state.py | 42 ++ .../components/input_weekday/services.yaml | 115 ++++ .../components/input_weekday/strings.json | 70 +++ homeassistant/generated/integrations.json | 5 + script/hassfest/manifest.py | 1 + script/hassfest/quality_scale.py | 1 + tests/components/input_weekday/__init__.py | 1 + tests/components/input_weekday/test_init.py | 518 ++++++++++++++++++ .../components/input_weekday/test_recorder.py | 37 ++ .../input_weekday/test_reproduce_state.py | 59 ++ 14 files changed, 1172 insertions(+) create mode 100644 homeassistant/components/input_weekday/__init__.py create mode 100644 homeassistant/components/input_weekday/icons.json create mode 100644 homeassistant/components/input_weekday/manifest.json create mode 100644 homeassistant/components/input_weekday/reproduce_state.py create mode 100644 homeassistant/components/input_weekday/services.yaml create mode 100644 homeassistant/components/input_weekday/strings.json create mode 100644 tests/components/input_weekday/__init__.py create mode 100644 tests/components/input_weekday/test_init.py create mode 100644 tests/components/input_weekday/test_recorder.py create mode 100644 tests/components/input_weekday/test_reproduce_state.py diff --git a/CODEOWNERS b/CODEOWNERS index cae176825166..b735d02d47a0 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -753,6 +753,8 @@ build.json @home-assistant/supervisor /tests/components/input_select/ @home-assistant/core /homeassistant/components/input_text/ @home-assistant/core /tests/components/input_text/ @home-assistant/core +/homeassistant/components/input_weekday/ @home-assistant/core +/tests/components/input_weekday/ @home-assistant/core /homeassistant/components/insteon/ @teharris1 /tests/components/insteon/ @teharris1 /homeassistant/components/integration/ @dgomes diff --git a/homeassistant/components/input_weekday/__init__.py b/homeassistant/components/input_weekday/__init__.py new file mode 100644 index 000000000000..62d428341056 --- /dev/null +++ b/homeassistant/components/input_weekday/__init__.py @@ -0,0 +1,284 @@ +"""Support to select weekdays for use in automation.""" + +from __future__ import annotations + +import logging +from typing import Any, Self + +import voluptuous as vol + +from homeassistant.const import ( + ATTR_EDITABLE, + CONF_ICON, + CONF_ID, + CONF_NAME, + SERVICE_RELOAD, + WEEKDAYS, +) +from homeassistant.core import HomeAssistant, ServiceCall, callback +from homeassistant.helpers import collection, config_validation as cv +from homeassistant.helpers.entity_component import EntityComponent +from homeassistant.helpers.restore_state import RestoreEntity +import homeassistant.helpers.service +from homeassistant.helpers.storage import Store +from homeassistant.helpers.typing import ConfigType, VolDictType + +_LOGGER = logging.getLogger(__name__) + +DOMAIN = "input_weekday" + +CONF_WEEKDAYS = "weekdays" + +ATTR_WEEKDAYS = "weekdays" +ATTR_WEEKDAY = "weekday" + +SERVICE_SET_WEEKDAYS = "set_weekdays" +SERVICE_ADD_WEEKDAY = "add_weekday" +SERVICE_REMOVE_WEEKDAY = "remove_weekday" +SERVICE_TOGGLE_WEEKDAY = "toggle_weekday" +SERVICE_CLEAR = "clear" + +STORAGE_KEY = DOMAIN +STORAGE_VERSION = 1 + +STORAGE_FIELDS: VolDictType = { + vol.Required(CONF_NAME): vol.All(str, vol.Length(min=1)), + vol.Optional(CONF_WEEKDAYS, default=list): vol.All( + cv.ensure_list, [vol.In(WEEKDAYS)] + ), + vol.Optional(CONF_ICON): cv.icon, +} + + +def _cv_input_weekday(cfg: dict[str, Any]) -> dict[str, Any]: + """Configure validation helper for input weekday (voluptuous).""" + weekdays = cfg.get(CONF_WEEKDAYS, []) + # Remove duplicates while preserving order + cfg[CONF_WEEKDAYS] = list(dict.fromkeys(weekdays)) + return cfg + + +CONFIG_SCHEMA = vol.Schema( + { + DOMAIN: cv.schema_with_slug_keys( + vol.All( + { + vol.Optional(CONF_NAME): cv.string, + vol.Optional(CONF_WEEKDAYS, default=list): vol.All( + cv.ensure_list, [vol.In(WEEKDAYS)] + ), + vol.Optional(CONF_ICON): cv.icon, + }, + _cv_input_weekday, + ) + ) + }, + extra=vol.ALLOW_EXTRA, +) +RELOAD_SERVICE_SCHEMA = vol.Schema({}) + + +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up an input weekday.""" + component = EntityComponent[InputWeekday](_LOGGER, DOMAIN, hass) + + id_manager = collection.IDManager() + + yaml_collection = collection.YamlCollection( + logging.getLogger(f"{__name__}.yaml_collection"), id_manager + ) + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, yaml_collection, InputWeekday + ) + + storage_collection = InputWeekdayStorageCollection( + Store(hass, STORAGE_VERSION, STORAGE_KEY), + id_manager, + ) + collection.sync_entity_lifecycle( + hass, DOMAIN, DOMAIN, component, storage_collection, InputWeekday + ) + + await yaml_collection.async_load( + [{CONF_ID: id_, **cfg} for id_, cfg in config.get(DOMAIN, {}).items()] + ) + await storage_collection.async_load() + + collection.DictStorageCollectionWebsocket( + storage_collection, DOMAIN, DOMAIN, STORAGE_FIELDS, STORAGE_FIELDS + ).async_setup(hass) + + async def reload_service_handler(service_call: ServiceCall) -> None: + """Reload yaml entities.""" + conf = await component.async_prepare_reload(skip_reset=True) + if conf is None: + conf = {DOMAIN: {}} + await yaml_collection.async_load( + [{CONF_ID: id_, **cfg} for id_, cfg in conf.get(DOMAIN, {}).items()] + ) + + homeassistant.helpers.service.async_register_admin_service( + hass, + DOMAIN, + SERVICE_RELOAD, + reload_service_handler, + schema=RELOAD_SERVICE_SCHEMA, + ) + + component.async_register_entity_service( + SERVICE_SET_WEEKDAYS, + {vol.Required(ATTR_WEEKDAYS): vol.All(cv.ensure_list, [vol.In(WEEKDAYS)])}, + "async_set_weekdays", + ) + + component.async_register_entity_service( + SERVICE_ADD_WEEKDAY, + {vol.Required(ATTR_WEEKDAY): vol.In(WEEKDAYS)}, + "async_add_weekday", + ) + + component.async_register_entity_service( + SERVICE_REMOVE_WEEKDAY, + {vol.Required(ATTR_WEEKDAY): vol.In(WEEKDAYS)}, + "async_remove_weekday", + ) + + component.async_register_entity_service( + SERVICE_TOGGLE_WEEKDAY, + {vol.Required(ATTR_WEEKDAY): vol.In(WEEKDAYS)}, + "async_toggle_weekday", + ) + + component.async_register_entity_service( + SERVICE_CLEAR, + None, + "async_clear", + ) + + return True + + +class InputWeekdayStorageCollection(collection.DictStorageCollection): + """Input weekday storage based collection.""" + + CREATE_UPDATE_SCHEMA = vol.Schema(vol.All(STORAGE_FIELDS, _cv_input_weekday)) + + async def _process_create_data(self, data: dict[str, Any]) -> dict[str, Any]: + """Validate the config is valid.""" + return self.CREATE_UPDATE_SCHEMA(data) + + @callback + def _get_suggested_id(self, info: dict[str, Any]) -> str: + """Suggest an ID based on the config.""" + return info[CONF_NAME] + + async def _update_data( + self, item: dict[str, Any], update_data: dict[str, Any] + ) -> dict[str, Any]: + """Return a new updated data object.""" + update_data = self.CREATE_UPDATE_SCHEMA(update_data) + return {CONF_ID: item[CONF_ID]} | update_data + + +# pylint: disable-next=hass-enforce-class-module +class InputWeekday(collection.CollectionEntity, RestoreEntity): + """Representation of a weekday input.""" + + _unrecorded_attributes = frozenset({ATTR_EDITABLE}) + + _attr_should_poll = False + editable: bool + + def __init__(self, config: ConfigType) -> None: + """Initialize a weekday input.""" + self._config = config + self._attr_weekdays = config.get(CONF_WEEKDAYS, []) + self._attr_unique_id = config[CONF_ID] + + @classmethod + def from_storage(cls, config: ConfigType) -> Self: + """Return entity instance initialized from storage.""" + input_weekday = cls(config) + input_weekday.editable = True + return input_weekday + + @classmethod + def from_yaml(cls, config: ConfigType) -> Self: + """Return entity instance initialized from yaml.""" + input_weekday = cls(config) + input_weekday.entity_id = f"{DOMAIN}.{config[CONF_ID]}" + input_weekday.editable = False + return input_weekday + + @property + def name(self) -> str | None: + """Return name of the weekday input.""" + return self._config.get(CONF_NAME) + + @property + def icon(self) -> str | None: + """Return the icon to be used for this entity.""" + return self._config.get(CONF_ICON) + + @property + def state(self) -> str: + """Return the state of the entity.""" + # Return a comma-separated string of selected weekdays + return ",".join(self._attr_weekdays) if self._attr_weekdays else "" + + @property + def extra_state_attributes(self) -> dict[str, Any]: + """Return the state attributes of the entity.""" + return { + ATTR_WEEKDAYS: self._attr_weekdays, + ATTR_EDITABLE: self.editable, + } + + async def async_added_to_hass(self) -> None: + """Call when entity about to be added to hass.""" + await super().async_added_to_hass() + + # Restore previous state if no initial weekdays were provided + if self._config.get(CONF_WEEKDAYS) is not None: + return + + state = await self.async_get_last_state() + if state is not None and ATTR_WEEKDAYS in state.attributes: + self._attr_weekdays = state.attributes[ATTR_WEEKDAYS] + + async def async_set_weekdays(self, weekdays: list[str]) -> None: + """Set the selected weekdays.""" + # Remove duplicates while preserving order + self._attr_weekdays = list(dict.fromkeys(weekdays)) + self.async_write_ha_state() + + async def async_add_weekday(self, weekday: str) -> None: + """Add a weekday to the selection.""" + if weekday not in self._attr_weekdays: + self._attr_weekdays.append(weekday) + self.async_write_ha_state() + + async def async_remove_weekday(self, weekday: str) -> None: + """Remove a weekday from the selection.""" + if weekday in self._attr_weekdays: + self._attr_weekdays.remove(weekday) + self.async_write_ha_state() + + async def async_toggle_weekday(self, weekday: str) -> None: + """Toggle a weekday in the selection.""" + if weekday in self._attr_weekdays: + self._attr_weekdays.remove(weekday) + else: + self._attr_weekdays.append(weekday) + self.async_write_ha_state() + + async def async_clear(self) -> None: + """Clear all selected weekdays.""" + self._attr_weekdays = [] + self.async_write_ha_state() + + async def async_update_config(self, config: ConfigType) -> None: + """Handle when the config is updated.""" + self._config = config + self._attr_weekdays = config.get(CONF_WEEKDAYS, []) + self.async_write_ha_state() diff --git a/homeassistant/components/input_weekday/icons.json b/homeassistant/components/input_weekday/icons.json new file mode 100644 index 000000000000..3d91b982addd --- /dev/null +++ b/homeassistant/components/input_weekday/icons.json @@ -0,0 +1,29 @@ +{ + "entity": { + "input_weekday": { + "default": { + "default": "mdi:calendar-week" + } + } + }, + "services": { + "set_weekdays": { + "service": "mdi:calendar-edit" + }, + "add_weekday": { + "service": "mdi:calendar-plus" + }, + "remove_weekday": { + "service": "mdi:calendar-minus" + }, + "toggle_weekday": { + "service": "mdi:calendar-check" + }, + "clear": { + "service": "mdi:calendar-remove" + }, + "reload": { + "service": "mdi:reload" + } + } +} diff --git a/homeassistant/components/input_weekday/manifest.json b/homeassistant/components/input_weekday/manifest.json new file mode 100644 index 000000000000..6a425a394813 --- /dev/null +++ b/homeassistant/components/input_weekday/manifest.json @@ -0,0 +1,8 @@ +{ + "domain": "input_weekday", + "name": "Input Weekday", + "codeowners": ["@home-assistant/core"], + "documentation": "https://www.home-assistant.io/integrations/input_weekday", + "integration_type": "helper", + "quality_scale": "internal" +} diff --git a/homeassistant/components/input_weekday/reproduce_state.py b/homeassistant/components/input_weekday/reproduce_state.py new file mode 100644 index 000000000000..18b57f7b4cf8 --- /dev/null +++ b/homeassistant/components/input_weekday/reproduce_state.py @@ -0,0 +1,42 @@ +"""Reproduce an Input Weekday state.""" + +from __future__ import annotations + +import logging +from typing import Any + +from homeassistant.const import ATTR_ENTITY_ID +from homeassistant.core import Context, HomeAssistant, State + +from . import ATTR_WEEKDAYS, DOMAIN, SERVICE_SET_WEEKDAYS + +_LOGGER = logging.getLogger(__name__) + + +async def async_reproduce_states( + hass: HomeAssistant, + states: list[State], + *, + context: Context | None = None, + reproduce_options: dict[str, Any] | None = None, +) -> None: + """Reproduce Input Weekday states.""" + for state in states: + if ATTR_WEEKDAYS not in state.attributes: + _LOGGER.warning( + "Unable to reproduce state for %s: %s attribute is missing", + state.entity_id, + ATTR_WEEKDAYS, + ) + continue + + weekdays = state.attributes[ATTR_WEEKDAYS] + + service_data = { + ATTR_ENTITY_ID: state.entity_id, + ATTR_WEEKDAYS: weekdays, + } + + await hass.services.async_call( + DOMAIN, SERVICE_SET_WEEKDAYS, service_data, context=context, blocking=True + ) diff --git a/homeassistant/components/input_weekday/services.yaml b/homeassistant/components/input_weekday/services.yaml new file mode 100644 index 000000000000..545f0052037f --- /dev/null +++ b/homeassistant/components/input_weekday/services.yaml @@ -0,0 +1,115 @@ +set_weekdays: + target: + entity: + domain: input_weekday + fields: + weekdays: + required: true + example: '["mon", "wed", "fri"]' + selector: + select: + multiple: true + mode: list + options: + - value: mon + label: Monday + - value: tue + label: Tuesday + - value: wed + label: Wednesday + - value: thu + label: Thursday + - value: fri + label: Friday + - value: sat + label: Saturday + - value: sun + label: Sunday + +add_weekday: + target: + entity: + domain: input_weekday + fields: + weekday: + required: true + example: mon + selector: + select: + mode: dropdown + options: + - value: mon + label: Monday + - value: tue + label: Tuesday + - value: wed + label: Wednesday + - value: thu + label: Thursday + - value: fri + label: Friday + - value: sat + label: Saturday + - value: sun + label: Sunday + +remove_weekday: + target: + entity: + domain: input_weekday + fields: + weekday: + required: true + example: mon + selector: + select: + mode: dropdown + options: + - value: mon + label: Monday + - value: tue + label: Tuesday + - value: wed + label: Wednesday + - value: thu + label: Thursday + - value: fri + label: Friday + - value: sat + label: Saturday + - value: sun + label: Sunday + +toggle_weekday: + target: + entity: + domain: input_weekday + fields: + weekday: + required: true + example: mon + selector: + select: + mode: dropdown + options: + - value: mon + label: Monday + - value: tue + label: Tuesday + - value: wed + label: Wednesday + - value: thu + label: Thursday + - value: fri + label: Friday + - value: sat + label: Saturday + - value: sun + label: Sunday + +clear: + target: + entity: + domain: input_weekday + +reload: diff --git a/homeassistant/components/input_weekday/strings.json b/homeassistant/components/input_weekday/strings.json new file mode 100644 index 000000000000..e4beb996989c --- /dev/null +++ b/homeassistant/components/input_weekday/strings.json @@ -0,0 +1,70 @@ +{ + "title": "Input Weekday", + "entity_component": { + "_": { + "name": "[%key:component::input_weekday::title%]", + "state_attributes": { + "weekdays": { + "name": "Weekdays" + }, + "editable": { + "name": "[%key:common::generic::ui_managed%]", + "state": { + "true": "[%key:common::state::yes%]", + "false": "[%key:common::state::no%]" + } + } + } + } + }, + "services": { + "set_weekdays": { + "name": "Set weekdays", + "description": "Sets the selected weekdays.", + "fields": { + "weekdays": { + "name": "Weekdays", + "description": "List of weekdays to select." + } + } + }, + "add_weekday": { + "name": "Add weekday", + "description": "Adds a weekday to the selection.", + "fields": { + "weekday": { + "name": "Weekday", + "description": "Weekday to add." + } + } + }, + "remove_weekday": { + "name": "Remove weekday", + "description": "Removes a weekday from the selection.", + "fields": { + "weekday": { + "name": "Weekday", + "description": "Weekday to remove." + } + } + }, + "toggle_weekday": { + "name": "Toggle weekday", + "description": "Toggles a weekday in the selection.", + "fields": { + "weekday": { + "name": "Weekday", + "description": "Weekday to toggle." + } + } + }, + "clear": { + "name": "Clear", + "description": "Clears all selected weekdays." + }, + "reload": { + "name": "[%key:common::action::reload%]", + "description": "Reloads helpers from the YAML-configuration." + } + } +} diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3cf28545b785..e0ee3e48eba1 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -7922,6 +7922,10 @@ "integration_type": "helper", "config_flow": false }, + "input_weekday": { + "integration_type": "helper", + "config_flow": false + }, "integration": { "integration_type": "helper", "config_flow": true, @@ -8021,6 +8025,7 @@ "input_number", "input_select", "input_text", + "input_weekday", "integration", "irm_kmi", "islamic_prayer_times", diff --git a/script/hassfest/manifest.py b/script/hassfest/manifest.py index 74aad78dc6a4..49f71bf26138 100644 --- a/script/hassfest/manifest.py +++ b/script/hassfest/manifest.py @@ -91,6 +91,7 @@ NO_IOT_CLASS = [ "input_number", "input_select", "input_text", + "input_weekday", "intent_script", "intent", "logbook", diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 7468afab8904..30afb34b96a0 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -2214,6 +2214,7 @@ NO_QUALITY_SCALE = [ "input_number", "input_select", "input_text", + "input_weekday", "intent_script", "intent", "logbook", diff --git a/tests/components/input_weekday/__init__.py b/tests/components/input_weekday/__init__.py new file mode 100644 index 000000000000..7cda08bb781c --- /dev/null +++ b/tests/components/input_weekday/__init__.py @@ -0,0 +1 @@ +"""Tests for the Input Weekday component.""" diff --git a/tests/components/input_weekday/test_init.py b/tests/components/input_weekday/test_init.py new file mode 100644 index 000000000000..4544bd6668b1 --- /dev/null +++ b/tests/components/input_weekday/test_init.py @@ -0,0 +1,518 @@ +"""Tests for the Input Weekday component.""" + +from typing import Any +from unittest.mock import patch + +import pytest + +from homeassistant.components.input_weekday import ( + ATTR_WEEKDAY, + ATTR_WEEKDAYS, + DOMAIN, + SERVICE_ADD_WEEKDAY, + SERVICE_CLEAR, + SERVICE_REMOVE_WEEKDAY, + SERVICE_SET_WEEKDAYS, + SERVICE_TOGGLE_WEEKDAY, + STORAGE_VERSION, +) +from homeassistant.const import ( + ATTR_EDITABLE, + ATTR_ENTITY_ID, + ATTR_FRIENDLY_NAME, + SERVICE_RELOAD, +) +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers import entity_registry as er +from homeassistant.setup import async_setup_component + +from tests.common import mock_restore_cache +from tests.typing import WebSocketGenerator + + +@pytest.fixture +def storage_setup(hass: HomeAssistant, hass_storage: dict[str, Any]): + """Storage setup.""" + + async def _storage(items=None, config=None): + if items is None: + hass_storage[DOMAIN] = { + "key": DOMAIN, + "version": STORAGE_VERSION, + "data": { + "items": [ + { + "id": "from_storage", + "name": "from storage", + "weekdays": ["mon", "wed", "fri"], + } + ] + }, + } + else: + hass_storage[DOMAIN] = { + "key": DOMAIN, + "version": STORAGE_VERSION, + "data": {"items": items}, + } + if config is None: + config = {DOMAIN: {}} + return await async_setup_component(hass, DOMAIN, config) + + return _storage + + +@pytest.mark.parametrize( + "invalid_config", + [ + None, + {"name with space": None}, + {"bad_weekdays": {"weekdays": ["invalid"]}}, + ], +) +async def test_config(hass: HomeAssistant, invalid_config) -> None: + """Test config.""" + assert not await async_setup_component(hass, DOMAIN, {DOMAIN: invalid_config}) + + +async def test_set_weekdays(hass: HomeAssistant) -> None: + """Test set_weekdays service.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test_1": {"weekdays": ["mon", "tue"]}}}, + ) + entity_id = "input_weekday.test_1" + + state = hass.states.get(entity_id) + assert state.state == "mon,tue" + assert state.attributes[ATTR_WEEKDAYS] == ["mon", "tue"] + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_WEEKDAYS, + {ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAYS: ["wed", "thu", "fri"]}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.state == "wed,thu,fri" + assert state.attributes[ATTR_WEEKDAYS] == ["wed", "thu", "fri"] + + +async def test_set_weekdays_removes_duplicates(hass: HomeAssistant) -> None: + """Test set_weekdays removes duplicate weekdays.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test_1": {"weekdays": []}}}, + ) + entity_id = "input_weekday.test_1" + + await hass.services.async_call( + DOMAIN, + SERVICE_SET_WEEKDAYS, + {ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAYS: ["mon", "tue", "mon", "wed"]}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_WEEKDAYS] == ["mon", "tue", "wed"] + + +async def test_add_weekday(hass: HomeAssistant) -> None: + """Test add_weekday service.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test_1": {"weekdays": ["mon"]}}}, + ) + entity_id = "input_weekday.test_1" + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_WEEKDAYS] == ["mon"] + + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_WEEKDAY, + {ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAY: "wed"}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_WEEKDAYS] == ["mon", "wed"] + + # Adding duplicate should not add it again + await hass.services.async_call( + DOMAIN, + SERVICE_ADD_WEEKDAY, + {ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAY: "mon"}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_WEEKDAYS] == ["mon", "wed"] + + +async def test_remove_weekday(hass: HomeAssistant) -> None: + """Test remove_weekday service.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test_1": {"weekdays": ["mon", "wed", "fri"]}}}, + ) + entity_id = "input_weekday.test_1" + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_WEEKDAYS] == ["mon", "wed", "fri"] + + await hass.services.async_call( + DOMAIN, + SERVICE_REMOVE_WEEKDAY, + {ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAY: "wed"}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_WEEKDAYS] == ["mon", "fri"] + + # Removing non-existent weekday should not error + await hass.services.async_call( + DOMAIN, + SERVICE_REMOVE_WEEKDAY, + {ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAY: "wed"}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_WEEKDAYS] == ["mon", "fri"] + + +async def test_toggle_weekday(hass: HomeAssistant) -> None: + """Test toggle_weekday service.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test_1": {"weekdays": ["mon"]}}}, + ) + entity_id = "input_weekday.test_1" + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_WEEKDAYS] == ["mon"] + + # Toggle off (remove) + await hass.services.async_call( + DOMAIN, + SERVICE_TOGGLE_WEEKDAY, + {ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAY: "mon"}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_WEEKDAYS] == [] + + # Toggle on (add) + await hass.services.async_call( + DOMAIN, + SERVICE_TOGGLE_WEEKDAY, + {ATTR_ENTITY_ID: entity_id, ATTR_WEEKDAY: "tue"}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_WEEKDAYS] == ["tue"] + + +async def test_clear(hass: HomeAssistant) -> None: + """Test clear service.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test_1": {"weekdays": ["mon", "wed", "fri"]}}}, + ) + entity_id = "input_weekday.test_1" + + state = hass.states.get(entity_id) + assert state.attributes[ATTR_WEEKDAYS] == ["mon", "wed", "fri"] + + await hass.services.async_call( + DOMAIN, + SERVICE_CLEAR, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state.state == "" + assert state.attributes[ATTR_WEEKDAYS] == [] + + +async def test_config_with_name(hass: HomeAssistant) -> None: + """Test configuration with name.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test_1": {"name": "Test Weekday", "weekdays": ["sat", "sun"]}}}, + ) + + state = hass.states.get("input_weekday.test_1") + assert state is not None + assert state.attributes[ATTR_FRIENDLY_NAME] == "Test Weekday" + assert state.attributes[ATTR_WEEKDAYS] == ["sat", "sun"] + + +async def test_empty_weekdays(hass: HomeAssistant) -> None: + """Test empty weekdays configuration.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test_1": {"weekdays": []}}}, + ) + + state = hass.states.get("input_weekday.test_1") + assert state is not None + assert state.state == "" + assert state.attributes[ATTR_WEEKDAYS] == [] + + +async def test_default_weekdays(hass: HomeAssistant) -> None: + """Test default weekdays (empty list).""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test_1": {}}}, + ) + + state = hass.states.get("input_weekday.test_1") + assert state is not None + assert state.state == "" + assert state.attributes[ATTR_WEEKDAYS] == [] + + +async def test_config_removes_duplicates(hass: HomeAssistant) -> None: + """Test that configuration removes duplicate weekdays.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test_1": {"weekdays": ["mon", "tue", "mon", "wed"]}}}, + ) + + state = hass.states.get("input_weekday.test_1") + assert state is not None + assert state.attributes[ATTR_WEEKDAYS] == ["mon", "tue", "wed"] + + +async def test_reload(hass: HomeAssistant) -> None: + """Test reload service.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test_1": {"weekdays": ["mon"]}}}, + ) + + state_1 = hass.states.get("input_weekday.test_1") + state_2 = hass.states.get("input_weekday.test_2") + + assert state_1 is not None + assert state_2 is None + assert state_1.attributes[ATTR_WEEKDAYS] == ["mon"] + + with patch( + "homeassistant.config.load_yaml_config_file", + return_value={ + DOMAIN: { + "test_2": {"weekdays": ["tue", "thu"]}, + } + }, + ): + await hass.services.async_call( + DOMAIN, + SERVICE_RELOAD, + blocking=True, + ) + await hass.async_block_till_done() + + state_1 = hass.states.get("input_weekday.test_1") + state_2 = hass.states.get("input_weekday.test_2") + + assert state_1 is None + assert state_2 is not None + assert state_2.attributes[ATTR_WEEKDAYS] == ["tue", "thu"] + + +async def test_state_restoration(hass: HomeAssistant) -> None: + """Test state restoration.""" + mock_restore_cache( + hass, + ( + State( + "input_weekday.test_1", + "mon,wed,fri", + {ATTR_WEEKDAYS: ["mon", "wed", "fri"]}, + ), + ), + ) + + hass.state = "starting" + + await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test_1": {}}}, + ) + + state = hass.states.get("input_weekday.test_1") + assert state + assert state.attributes[ATTR_WEEKDAYS] == ["mon", "wed", "fri"] + + +async def test_state_restoration_with_initial(hass: HomeAssistant) -> None: + """Test state restoration with initial value - should prefer initial.""" + mock_restore_cache( + hass, + ( + State( + "input_weekday.test_1", + "mon,wed,fri", + {ATTR_WEEKDAYS: ["mon", "wed", "fri"]}, + ), + ), + ) + + hass.state = "starting" + + await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test_1": {"weekdays": ["sat", "sun"]}}}, + ) + + state = hass.states.get("input_weekday.test_1") + assert state + assert state.attributes[ATTR_WEEKDAYS] == ["sat", "sun"] + + +async def test_storage(hass: HomeAssistant, storage_setup) -> None: + """Test storage.""" + assert await storage_setup() + state = hass.states.get("input_weekday.from_storage") + assert state.attributes[ATTR_WEEKDAYS] == ["mon", "wed", "fri"] + assert state.attributes[ATTR_EDITABLE] + + +async def test_editable_state_attribute(hass: HomeAssistant) -> None: + """Test editable attribute.""" + assert await async_setup_component( + hass, + DOMAIN, + {DOMAIN: {"test_1": {"weekdays": ["mon"]}}}, + ) + + state = hass.states.get("input_weekday.test_1") + assert state.attributes[ATTR_EDITABLE] is False + + +async def test_websocket_create( + hass: HomeAssistant, hass_ws_client: WebSocketGenerator +) -> None: + """Test create via websocket.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": f"{DOMAIN}/create", + "name": "My Weekday", + "weekdays": ["mon", "fri"], + } + ) + resp = await client.receive_json() + assert resp["success"] + + state = hass.states.get("input_weekday.my_weekday") + assert state.attributes[ATTR_WEEKDAYS] == ["mon", "fri"] + + +async def test_websocket_update( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, +) -> None: + """Test update via websocket.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": f"{DOMAIN}/create", + "name": "My Weekday", + "weekdays": ["mon"], + } + ) + resp = await client.receive_json() + assert resp["success"] + + state = hass.states.get("input_weekday.my_weekday") + assert state.attributes[ATTR_WEEKDAYS] == ["mon"] + + entity_entry = entity_registry.async_get("input_weekday.my_weekday") + + await client.send_json( + { + "id": 2, + "type": f"{DOMAIN}/update", + f"{DOMAIN}_id": entity_entry.id, + "weekdays": ["tue", "wed"], + "name": "Updated Weekday", + } + ) + resp = await client.receive_json() + assert resp["success"] + + state = hass.states.get("input_weekday.my_weekday") + assert state.attributes[ATTR_WEEKDAYS] == ["tue", "wed"] + assert state.attributes[ATTR_FRIENDLY_NAME] == "Updated Weekday" + + +async def test_websocket_delete( + hass: HomeAssistant, + hass_ws_client: WebSocketGenerator, + entity_registry: er.EntityRegistry, +) -> None: + """Test delete via websocket.""" + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {}}) + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": f"{DOMAIN}/create", + "name": "My Weekday", + "weekdays": ["mon"], + } + ) + resp = await client.receive_json() + assert resp["success"] + + state = hass.states.get("input_weekday.my_weekday") + assert state is not None + + entity_entry = entity_registry.async_get("input_weekday.my_weekday") + + await client.send_json( + { + "id": 2, + "type": f"{DOMAIN}/delete", + f"{DOMAIN}_id": entity_entry.id, + } + ) + resp = await client.receive_json() + assert resp["success"] + + state = hass.states.get("input_weekday.my_weekday") + assert state is None diff --git a/tests/components/input_weekday/test_recorder.py b/tests/components/input_weekday/test_recorder.py new file mode 100644 index 000000000000..0eb0867f9243 --- /dev/null +++ b/tests/components/input_weekday/test_recorder.py @@ -0,0 +1,37 @@ +"""Tests for the Input Weekday recorder.""" + +from homeassistant.components.input_weekday import ATTR_EDITABLE, ATTR_WEEKDAYS +from homeassistant.components.recorder import Recorder +from homeassistant.components.recorder.history import get_significant_states +from homeassistant.const import ATTR_FRIENDLY_NAME +from homeassistant.core import HomeAssistant +from homeassistant.setup import async_setup_component +from homeassistant.util import dt as dt_util + +from tests.components.recorder.common import async_wait_recording_done + + +async def test_exclude_attributes(recorder_mock: Recorder, hass: HomeAssistant) -> None: + """Test that certain attributes are excluded.""" + now = dt_util.utcnow() + assert await async_setup_component( + hass, + "input_weekday", + {"input_weekday": {"test": {"weekdays": ["mon", "wed"]}}}, + ) + + state = hass.states.get("input_weekday.test") + assert state.attributes[ATTR_WEEKDAYS] == ["mon", "wed"] + assert state.attributes[ATTR_EDITABLE] is False + + await async_wait_recording_done(hass) + + states = await hass.async_add_executor_job( + get_significant_states, hass, now, None, ["input_weekday.test"] + ) + assert len(states) == 1 + for entity_states in states.values(): + for state in entity_states: + assert ATTR_WEEKDAYS in state.attributes + assert ATTR_EDITABLE not in state.attributes + assert ATTR_FRIENDLY_NAME in state.attributes diff --git a/tests/components/input_weekday/test_reproduce_state.py b/tests/components/input_weekday/test_reproduce_state.py new file mode 100644 index 000000000000..5c1ea37dab6e --- /dev/null +++ b/tests/components/input_weekday/test_reproduce_state.py @@ -0,0 +1,59 @@ +"""Test reproduce state for Input Weekday.""" + +import pytest + +from homeassistant.components.input_weekday import ATTR_WEEKDAYS, DOMAIN +from homeassistant.core import HomeAssistant, State +from homeassistant.helpers.state import async_reproduce_state +from homeassistant.setup import async_setup_component + +from tests.common import async_mock_service + + +@pytest.fixture +async def setup_component(hass: HomeAssistant): + """Set up component.""" + assert await async_setup_component( + hass, DOMAIN, {DOMAIN: {"test_weekday": {"weekdays": []}}} + ) + + +async def test_reproduce_weekday(hass: HomeAssistant) -> None: + """Test reproduce weekday.""" + calls = async_mock_service(hass, DOMAIN, "set_weekdays") + + await async_reproduce_state( + hass, + [ + State( + "input_weekday.test_weekday", + "mon,wed,fri", + {ATTR_WEEKDAYS: ["mon", "wed", "fri"]}, + ) + ], + ) + + await hass.async_block_till_done() + + assert len(calls) == 1 + assert calls[0].data == { + "entity_id": "input_weekday.test_weekday", + ATTR_WEEKDAYS: ["mon", "wed", "fri"], + } + + +async def test_reproduce_weekday_missing_attribute( + hass: HomeAssistant, setup_component, caplog: pytest.LogCaptureFixture +) -> None: + """Test reproduce weekday with missing weekdays attribute.""" + calls = async_mock_service(hass, DOMAIN, "set_weekdays") + + await async_reproduce_state( + hass, + [State("input_weekday.test_weekday", "mon,wed")], + ) + + await hass.async_block_till_done() + + assert len(calls) == 0 + assert "weekdays attribute is missing" in caplog.text