diff --git a/homeassistant/components/knx/__init__.py b/homeassistant/components/knx/__init__.py index c30098f254b..f0ee9576cc7 100644 --- a/homeassistant/components/knx/__init__.py +++ b/homeassistant/components/knx/__init__.py @@ -80,6 +80,7 @@ from .schema import ( ButtonSchema, ClimateSchema, CoverSchema, + DateSchema, EventSchema, ExposeSchema, FanSchema, @@ -136,6 +137,7 @@ CONFIG_SCHEMA = vol.Schema( **ButtonSchema.platform_node(), **ClimateSchema.platform_node(), **CoverSchema.platform_node(), + **DateSchema.platform_node(), **FanSchema.platform_node(), **LightSchema.platform_node(), **NotifySchema.platform_node(), diff --git a/homeassistant/components/knx/const.py b/homeassistant/components/knx/const.py index bdc480851c3..c96f10736dd 100644 --- a/homeassistant/components/knx/const.py +++ b/homeassistant/components/knx/const.py @@ -127,6 +127,7 @@ SUPPORTED_PLATFORMS: Final = [ Platform.BUTTON, Platform.CLIMATE, Platform.COVER, + Platform.DATE, Platform.FAN, Platform.LIGHT, Platform.NOTIFY, diff --git a/homeassistant/components/knx/date.py b/homeassistant/components/knx/date.py new file mode 100644 index 00000000000..1f286d59ecb --- /dev/null +++ b/homeassistant/components/knx/date.py @@ -0,0 +1,100 @@ +"""Support for KNX/IP date.""" +from __future__ import annotations + +from datetime import date as dt_date +import time +from typing import Final + +from xknx import XKNX +from xknx.devices import DateTime as XknxDateTime + +from homeassistant import config_entries +from homeassistant.components.date import DateEntity +from homeassistant.const import ( + CONF_ENTITY_CATEGORY, + CONF_NAME, + STATE_UNAVAILABLE, + STATE_UNKNOWN, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddEntitiesCallback +from homeassistant.helpers.restore_state import RestoreEntity +from homeassistant.helpers.typing import ConfigType + +from .const import ( + CONF_RESPOND_TO_READ, + CONF_STATE_ADDRESS, + CONF_SYNC_STATE, + DATA_KNX_CONFIG, + DOMAIN, + KNX_ADDRESS, +) +from .knx_entity import KnxEntity + +_DATE_TRANSLATION_FORMAT: Final = "%Y-%m-%d" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: config_entries.ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up entities for KNX platform.""" + xknx: XKNX = hass.data[DOMAIN].xknx + config: list[ConfigType] = hass.data[DATA_KNX_CONFIG][Platform.DATE] + + async_add_entities(KNXDate(xknx, entity_config) for entity_config in config) + + +def _create_xknx_device(xknx: XKNX, config: ConfigType) -> XknxDateTime: + """Return a XKNX DateTime object to be used within XKNX.""" + return XknxDateTime( + xknx, + name=config[CONF_NAME], + broadcast_type="DATE", + localtime=False, + group_address=config[KNX_ADDRESS], + group_address_state=config.get(CONF_STATE_ADDRESS), + respond_to_read=config[CONF_RESPOND_TO_READ], + sync_state=config[CONF_SYNC_STATE], + ) + + +class KNXDate(KnxEntity, DateEntity, RestoreEntity): + """Representation of a KNX date.""" + + _device: XknxDateTime + + def __init__(self, xknx: XKNX, config: ConfigType) -> None: + """Initialize a KNX time.""" + super().__init__(_create_xknx_device(xknx, config)) + self._attr_entity_category = config.get(CONF_ENTITY_CATEGORY) + self._attr_unique_id = str(self._device.remote_value.group_address) + + async def async_added_to_hass(self) -> None: + """Restore last state.""" + await super().async_added_to_hass() + if ( + not self._device.remote_value.readable + and (last_state := await self.async_get_last_state()) is not None + and last_state.state not in (STATE_UNKNOWN, STATE_UNAVAILABLE) + ): + self._device.remote_value.value = time.strptime( + last_state.state, _DATE_TRANSLATION_FORMAT + ) + + @property + def native_value(self) -> dt_date | None: + """Return the latest value.""" + if (time_struct := self._device.remote_value.value) is None: + return None + return dt_date( + year=time_struct.tm_year, + month=time_struct.tm_mon, + day=time_struct.tm_mday, + ) + + async def async_set_value(self, value: dt_date) -> None: + """Change the value.""" + await self._device.set(value.timetuple()) diff --git a/homeassistant/components/knx/schema.py b/homeassistant/components/knx/schema.py index 86bf790a077..40cc2232d8f 100644 --- a/homeassistant/components/knx/schema.py +++ b/homeassistant/components/knx/schema.py @@ -556,6 +556,25 @@ class CoverSchema(KNXPlatformSchema): ) +class DateSchema(KNXPlatformSchema): + """Voluptuous schema for KNX date.""" + + PLATFORM = Platform.DATE + + DEFAULT_NAME = "KNX Date" + + ENTITY_SCHEMA = vol.Schema( + { + vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, + vol.Optional(CONF_RESPOND_TO_READ, default=False): cv.boolean, + vol.Optional(CONF_SYNC_STATE, default=True): sync_state_validator, + vol.Required(KNX_ADDRESS): ga_list_validator, + vol.Optional(CONF_STATE_ADDRESS): ga_list_validator, + vol.Optional(CONF_ENTITY_CATEGORY): ENTITY_CATEGORIES_SCHEMA, + } + ) + + class ExposeSchema(KNXPlatformSchema): """Voluptuous schema for KNX exposures.""" diff --git a/tests/components/knx/test_date.py b/tests/components/knx/test_date.py new file mode 100644 index 00000000000..bfde519f3c0 --- /dev/null +++ b/tests/components/knx/test_date.py @@ -0,0 +1,86 @@ +"""Test KNX date.""" +from homeassistant.components.date import ATTR_DATE, DOMAIN, SERVICE_SET_VALUE +from homeassistant.components.knx.const import CONF_RESPOND_TO_READ, KNX_ADDRESS +from homeassistant.components.knx.schema import DateSchema +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, State + +from .conftest import KNXTestKit + +from tests.common import mock_restore_cache + + +async def test_date(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX date.""" + test_address = "1/1/1" + await knx.setup_integration( + { + DateSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: test_address, + } + } + ) + # set value + await hass.services.async_call( + DOMAIN, + SERVICE_SET_VALUE, + {"entity_id": "date.test", ATTR_DATE: "1999-03-31"}, + blocking=True, + ) + await knx.assert_write( + test_address, + (0x1F, 0x03, 0x63), + ) + state = hass.states.get("date.test") + assert state.state == "1999-03-31" + + # update from KNX + await knx.receive_write( + test_address, + (0x01, 0x02, 0x03), + ) + state = hass.states.get("date.test") + assert state.state == "2003-02-01" + + +async def test_date_restore_and_respond(hass: HomeAssistant, knx: KNXTestKit) -> None: + """Test KNX date with passive_address, restoring state and respond_to_read.""" + test_address = "1/1/1" + test_passive_address = "3/3/3" + + fake_state = State("date.test", "2023-07-24") + mock_restore_cache(hass, (fake_state,)) + + await knx.setup_integration( + { + DateSchema.PLATFORM: { + CONF_NAME: "test", + KNX_ADDRESS: [test_address, test_passive_address], + CONF_RESPOND_TO_READ: True, + } + } + ) + # restored state - doesn't send telegram + state = hass.states.get("date.test") + assert state.state == "2023-07-24" + await knx.assert_telegram_count(0) + + # respond with restored state + await knx.receive_read(test_address) + await knx.assert_response( + test_address, + (0x18, 0x07, 0x17), + ) + + # don't respond to passive address + await knx.receive_read(test_passive_address) + await knx.assert_no_telegram() + + # update from KNX passive address + await knx.receive_write( + test_passive_address, + (0x18, 0x02, 0x18), + ) + state = hass.states.get("date.test") + assert state.state == "2024-02-24"