diff --git a/homeassistant/components/update/device_trigger.py b/homeassistant/components/update/device_trigger.py new file mode 100644 index 00000000000..690e67cce56 --- /dev/null +++ b/homeassistant/components/update/device_trigger.py @@ -0,0 +1,48 @@ +"""Provides device triggers for update entities.""" +from __future__ import annotations + +from typing import Any + +import voluptuous as vol + +from homeassistant.components.automation import ( + AutomationActionType, + AutomationTriggerInfo, +) +from homeassistant.components.device_automation import toggle_entity +from homeassistant.const import CONF_DOMAIN +from homeassistant.core import CALLBACK_TYPE, HomeAssistant +from homeassistant.helpers.typing import ConfigType + +from . import DOMAIN + +TRIGGER_SCHEMA = vol.All( + toggle_entity.TRIGGER_SCHEMA, + vol.Schema({vol.Required(CONF_DOMAIN): DOMAIN}, extra=vol.ALLOW_EXTRA), +) + + +async def async_attach_trigger( + hass: HomeAssistant, + config: ConfigType, + action: AutomationActionType, + automation_info: AutomationTriggerInfo, +) -> CALLBACK_TYPE: + """Listen for state changes based on configuration.""" + return await toggle_entity.async_attach_trigger( + hass, config, action, automation_info + ) + + +async def async_get_triggers( + hass: HomeAssistant, device_id: str +) -> list[dict[str, Any]]: + """List device triggers.""" + return await toggle_entity.async_get_triggers(hass, device_id, DOMAIN) + + +async def async_get_trigger_capabilities( + hass: HomeAssistant, config: ConfigType +) -> dict[str, vol.Schema]: + """List trigger capabilities.""" + return await toggle_entity.async_get_trigger_capabilities(hass, config) diff --git a/homeassistant/components/update/strings.json b/homeassistant/components/update/strings.json index b079c9ec8b6..c26d3968ae1 100644 --- a/homeassistant/components/update/strings.json +++ b/homeassistant/components/update/strings.json @@ -1,3 +1,10 @@ { - "title": "Update" + "title": "Update", + "device_automation": { + "trigger_type": { + "changed_states": "{entity_name} update availability changed", + "turned_on": "{entity_name} got an update available", + "turned_off": "{entity_name} became up-to-date" + } + } } diff --git a/homeassistant/components/update/translations/en.json b/homeassistant/components/update/translations/en.json index 95b82de3b4d..5b07e7c5245 100644 --- a/homeassistant/components/update/translations/en.json +++ b/homeassistant/components/update/translations/en.json @@ -1,3 +1,10 @@ { + "device_automation": { + "trigger_type": { + "changed_states": "{entity_name} update availability changed", + "turned_off": "{entity_name} became up-to-date", + "turned_on": "{entity_name} got an update available" + } + }, "title": "Update" } \ No newline at end of file diff --git a/tests/components/update/test_device_trigger.py b/tests/components/update/test_device_trigger.py new file mode 100644 index 00000000000..e8aed1b86c9 --- /dev/null +++ b/tests/components/update/test_device_trigger.py @@ -0,0 +1,261 @@ +"""The test for update device automation.""" +from datetime import timedelta + +import pytest + +import homeassistant.components.automation as automation +from homeassistant.components.device_automation import DeviceAutomationType +from homeassistant.components.update import DOMAIN +from homeassistant.const import CONF_PLATFORM, STATE_OFF, STATE_ON +from homeassistant.core import HomeAssistant, ServiceCall +from homeassistant.helpers import device_registry +from homeassistant.helpers.device_registry import DeviceRegistry +from homeassistant.helpers.entity_registry import EntityRegistry +from homeassistant.setup import async_setup_component +import homeassistant.util.dt as dt_util + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_get_device_automation_capabilities, + async_get_device_automations, + async_mock_service, + mock_device_registry, + mock_registry, +) +from tests.components.blueprint.conftest import stub_blueprint_populate # noqa: F401 + + +@pytest.fixture +def device_reg(hass: HomeAssistant) -> DeviceRegistry: + """Return an empty, loaded, registry.""" + return mock_device_registry(hass) + + +@pytest.fixture +def entity_reg(hass: HomeAssistant) -> EntityRegistry: + """Return an empty, loaded, registry.""" + return mock_registry(hass) + + +@pytest.fixture +def calls(hass: HomeAssistant) -> list[ServiceCall]: + """Track calls to a mock service.""" + return async_mock_service(hass, "test", "automation") + + +async def test_get_triggers( + hass: HomeAssistant, device_reg: DeviceRegistry, entity_reg: EntityRegistry +) -> None: + """Test we get the expected triggers from a update entity.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) + expected_triggers = [ + { + "platform": "device", + "domain": DOMAIN, + "type": "changed_states", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "turned_off", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + { + "platform": "device", + "domain": DOMAIN, + "type": "turned_on", + "device_id": device_entry.id, + "entity_id": f"{DOMAIN}.test_5678", + }, + ] + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + assert triggers == expected_triggers + + +async def test_get_trigger_capabilities( + hass: HomeAssistant, device_reg: DeviceRegistry, entity_reg: EntityRegistry +) -> None: + """Test we get the expected capabilities from a update trigger.""" + config_entry = MockConfigEntry(domain="test", data={}) + config_entry.add_to_hass(hass) + device_entry = device_reg.async_get_or_create( + config_entry_id=config_entry.entry_id, + connections={(device_registry.CONNECTION_NETWORK_MAC, "12:34:56:AB:CD:EF")}, + ) + entity_reg.async_get_or_create(DOMAIN, "test", "5678", device_id=device_entry.id) + expected_capabilities = { + "extra_fields": [ + {"name": "for", "optional": True, "type": "positive_time_period_dict"} + ] + } + triggers = await async_get_device_automations( + hass, DeviceAutomationType.TRIGGER, device_entry.id + ) + for trigger in triggers: + capabilities = await async_get_device_automation_capabilities( + hass, DeviceAutomationType.TRIGGER, trigger + ) + assert capabilities == expected_capabilities + + +async def test_if_fires_on_state_change( + hass: HomeAssistant, calls: list[ServiceCall], enable_custom_integrations: None +) -> None: + """Test for turn_on and turn_off triggers firing.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "update.update_available", + "type": "turned_on", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "update_available {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "update.update_available", + "type": "turned_off", + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "no_update {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + }, + ] + }, + ) + await hass.async_block_till_done() + state = hass.states.get("update.update_available") + assert state + assert state.state == STATE_ON + assert not calls + + hass.states.async_set("update.update_available", STATE_OFF) + await hass.async_block_till_done() + assert len(calls) == 1 + assert ( + calls[0].data["some"] + == "no_update device - update.update_available - on - off - None" + ) + + hass.states.async_set("update.update_available", STATE_ON) + await hass.async_block_till_done() + assert len(calls) == 2 + assert ( + calls[1].data["some"] + == "update_available device - update.update_available - off - on - None" + ) + + +async def test_if_fires_on_state_change_with_for( + hass: HomeAssistant, calls: list[ServiceCall], enable_custom_integrations: None +) -> None: + """Test for triggers firing with delay.""" + platform = getattr(hass.components, f"test.{DOMAIN}") + + platform.init() + assert await async_setup_component(hass, DOMAIN, {DOMAIN: {CONF_PLATFORM: "test"}}) + await hass.async_block_till_done() + + assert await async_setup_component( + hass, + automation.DOMAIN, + { + automation.DOMAIN: [ + { + "trigger": { + "platform": "device", + "domain": DOMAIN, + "device_id": "", + "entity_id": "update.update_available", + "type": "turned_off", + "for": {"seconds": 5}, + }, + "action": { + "service": "test.automation", + "data_template": { + "some": "turn_off {{ trigger.%s }}" + % "}} - {{ trigger.".join( + ( + "platform", + "entity_id", + "from_state.state", + "to_state.state", + "for", + ) + ) + }, + }, + } + ] + }, + ) + await hass.async_block_till_done() + state = hass.states.get("update.update_available") + assert state + assert state.state == STATE_ON + assert not calls + + hass.states.async_set("update.update_available", STATE_OFF) + await hass.async_block_till_done() + assert not calls + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=10)) + await hass.async_block_till_done() + assert len(calls) == 1 + await hass.async_block_till_done() + assert ( + calls[0].data["some"] + == "turn_off device - update.update_available - on - off - 0:00:05" + )