Add trigger template alarm control panels (#145461)

* Add trigger template alarm control panels

* updates

* fix jumbled imports

* fix comments
This commit is contained in:
Petro31 2025-06-23 10:10:50 -04:00 committed by GitHub
parent b48ebeaa8a
commit c1e32aa9b7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 205 additions and 40 deletions

View File

@ -10,6 +10,7 @@ from typing import TYPE_CHECKING, Any
import voluptuous as vol
from homeassistant.components.alarm_control_panel import (
DOMAIN as ALARM_CONTROL_PANEL_DOMAIN,
ENTITY_ID_FORMAT,
PLATFORM_SCHEMA as ALARM_CONTROL_PANEL_PLATFORM_SCHEMA,
AlarmControlPanelEntity,
@ -42,6 +43,7 @@ from homeassistant.helpers.script import Script
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
from .const import CONF_OBJECT_ID, DOMAIN
from .coordinator import TriggerUpdateCoordinator
from .entity import AbstractTemplateEntity
from .template_entity import (
LEGACY_FIELDS as TEMPLATE_ENTITY_LEGACY_FIELDS,
@ -49,6 +51,7 @@ from .template_entity import (
make_template_entity_common_modern_schema,
rewrite_common_legacy_to_modern_conf,
)
from .trigger_entity import TriggerEntity
_LOGGER = logging.getLogger(__name__)
_VALID_STATES = [
@ -253,6 +256,13 @@ async def async_setup_platform(
)
return
if "coordinator" in discovery_info:
async_add_entities(
TriggerAlarmControlPanelEntity(hass, discovery_info["coordinator"], config)
for config in discovery_info["entities"]
)
return
_async_create_template_tracking_entities(
async_add_entities,
hass,
@ -276,8 +286,11 @@ class AbstractTemplateAlarmControlPanel(
self._attr_code_format = config[CONF_CODE_FORMAT].value
self._state: AlarmControlPanelState | None = None
self._attr_supported_features: AlarmControlPanelEntityFeature = (
AlarmControlPanelEntityFeature(0)
)
def _register_scripts(
def _iterate_scripts(
self, config: dict[str, Any]
) -> Generator[
tuple[str, Sequence[dict[str, Any]], AlarmControlPanelEntityFeature | int]
@ -423,8 +436,7 @@ class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPane
if TYPE_CHECKING:
assert name is not None
self._attr_supported_features = AlarmControlPanelEntityFeature(0)
for action_id, action_config, supported_feature in self._register_scripts(
for action_id, action_config, supported_feature in self._iterate_scripts(
config
):
self.add_script(action_id, action_config, name, DOMAIN)
@ -456,3 +468,55 @@ class AlarmControlPanelTemplate(TemplateEntity, AbstractTemplateAlarmControlPane
"_state", self._template, None, self._update_state
)
super()._async_setup_templates()
class TriggerAlarmControlPanelEntity(TriggerEntity, AbstractTemplateAlarmControlPanel):
"""Alarm Control Panel entity based on trigger data."""
domain = ALARM_CONTROL_PANEL_DOMAIN
def __init__(
self,
hass: HomeAssistant,
coordinator: TriggerUpdateCoordinator,
config: ConfigType,
) -> None:
"""Initialize the entity."""
TriggerEntity.__init__(self, hass, coordinator, config)
AbstractTemplateAlarmControlPanel.__init__(self, config)
self._attr_name = name = self._rendered.get(CONF_NAME, DEFAULT_NAME)
if isinstance(config.get(CONF_STATE), template.Template):
self._to_render_simple.append(CONF_STATE)
self._parse_result.add(CONF_STATE)
for action_id, action_config, supported_feature in self._iterate_scripts(
config
):
self.add_script(action_id, action_config, name, DOMAIN)
self._attr_supported_features |= supported_feature
self._attr_device_info = async_device_info_to_link_from_device_id(
hass,
config.get(CONF_DEVICE_ID),
)
async def async_added_to_hass(self) -> None:
"""Restore last state."""
await super().async_added_to_hass()
await self._async_handle_restored_state()
@callback
def _handle_coordinator_update(self) -> None:
"""Handle update of the data."""
self._process_data()
if not self.available:
self.async_write_ha_state()
return
if (rendered := self._rendered.get(CONF_STATE)) is not None:
self._handle_state(rendered)
self.async_set_context(self.coordinator.data["context"])
self.async_write_ha_state()

View File

@ -157,7 +157,6 @@ CONFIG_SECTION_SCHEMA = vol.All(
},
),
ensure_domains_do_not_have_trigger_or_action(
DOMAIN_ALARM_CONTROL_PANEL,
DOMAIN_BUTTON,
DOMAIN_FAN,
DOMAIN_LOCK,

View File

@ -30,6 +30,7 @@ from tests.common import MockConfigEntry, assert_setup_component, mock_restore_c
TEST_OBJECT_ID = "test_template_panel"
TEST_ENTITY_ID = f"alarm_control_panel.{TEST_OBJECT_ID}"
TEST_STATE_ENTITY_ID = "alarm_control_panel.test"
TEST_SWITCH = "switch.test_state"
@pytest.fixture
@ -110,6 +111,14 @@ TEMPLATE_ALARM_CONFIG = {
**OPTIMISTIC_TEMPLATE_ALARM_CONFIG,
}
TEST_STATE_TRIGGER = {
"triggers": {"trigger": "state", "entity_id": [TEST_STATE_ENTITY_ID, TEST_SWITCH]},
"variables": {"triggering_entity": "{{ trigger.entity_id }}"},
"actions": [
{"event": "action_event", "event_data": {"what": "{{ triggering_entity }}"}}
],
}
async def async_setup_legacy_format(
hass: HomeAssistant, count: int, panel_config: dict[str, Any]
@ -146,6 +155,24 @@ async def async_setup_modern_format(
await hass.async_block_till_done()
async def async_setup_trigger_format(
hass: HomeAssistant, count: int, panel_config: dict[str, Any]
) -> None:
"""Do setup of alarm control panel integration via trigger format."""
config = {"template": {"alarm_control_panel": panel_config, **TEST_STATE_TRIGGER}}
with assert_setup_component(count, template.DOMAIN):
assert await async_setup_component(
hass,
template.DOMAIN,
config,
)
await hass.async_block_till_done()
await hass.async_start()
await hass.async_block_till_done()
@pytest.fixture
async def setup_panel(
hass: HomeAssistant,
@ -158,6 +185,8 @@ async def setup_panel(
await async_setup_legacy_format(hass, count, panel_config)
elif style == ConfigurationStyle.MODERN:
await async_setup_modern_format(hass, count, panel_config)
elif style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(hass, count, panel_config)
async def async_setup_state_panel(
@ -188,6 +217,16 @@ async def async_setup_state_panel(
**OPTIMISTIC_TEMPLATE_ALARM_CONFIG,
},
)
elif style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(
hass,
count,
{
"name": TEST_OBJECT_ID,
"state": state_template,
**OPTIMISTIC_TEMPLATE_ALARM_CONFIG,
},
)
@pytest.fixture
@ -228,6 +267,17 @@ async def setup_base_panel(
**panel_config,
},
)
elif style == ConfigurationStyle.TRIGGER:
extra = {"state": state_template} if state_template else {}
await async_setup_trigger_format(
hass,
count,
{
"name": TEST_OBJECT_ID,
**extra,
**panel_config,
},
)
@pytest.fixture
@ -264,13 +314,25 @@ async def setup_single_attribute_state_panel(
**extra,
},
)
elif style == ConfigurationStyle.TRIGGER:
await async_setup_trigger_format(
hass,
count,
{
"name": TEST_OBJECT_ID,
**OPTIMISTIC_TEMPLATE_ALARM_CONFIG,
"state": state_template,
**extra,
},
)
@pytest.mark.parametrize(
("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")]
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_state_panel")
async def test_template_state_text(hass: HomeAssistant) -> None:
@ -301,56 +363,72 @@ async def test_template_state_text(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("count", [1])
@pytest.mark.parametrize(
("state_template", "expected"),
("state_template", "expected", "trigger_expected"),
[
("{{ 'disarmed' }}", AlarmControlPanelState.DISARMED),
("{{ 'armed_home' }}", AlarmControlPanelState.ARMED_HOME),
("{{ 'armed_away' }}", AlarmControlPanelState.ARMED_AWAY),
("{{ 'armed_night' }}", AlarmControlPanelState.ARMED_NIGHT),
("{{ 'armed_vacation' }}", AlarmControlPanelState.ARMED_VACATION),
("{{ 'armed_custom_bypass' }}", AlarmControlPanelState.ARMED_CUSTOM_BYPASS),
("{{ 'pending' }}", AlarmControlPanelState.PENDING),
("{{ 'arming' }}", AlarmControlPanelState.ARMING),
("{{ 'disarming' }}", AlarmControlPanelState.DISARMING),
("{{ 'triggered' }}", AlarmControlPanelState.TRIGGERED),
("{{ x - 1 }}", STATE_UNKNOWN),
("{{ 'disarmed' }}", AlarmControlPanelState.DISARMED, None),
("{{ 'armed_home' }}", AlarmControlPanelState.ARMED_HOME, None),
("{{ 'armed_away' }}", AlarmControlPanelState.ARMED_AWAY, None),
("{{ 'armed_night' }}", AlarmControlPanelState.ARMED_NIGHT, None),
("{{ 'armed_vacation' }}", AlarmControlPanelState.ARMED_VACATION, None),
(
"{{ 'armed_custom_bypass' }}",
AlarmControlPanelState.ARMED_CUSTOM_BYPASS,
None,
),
("{{ 'pending' }}", AlarmControlPanelState.PENDING, None),
("{{ 'arming' }}", AlarmControlPanelState.ARMING, None),
("{{ 'disarming' }}", AlarmControlPanelState.DISARMING, None),
("{{ 'triggered' }}", AlarmControlPanelState.TRIGGERED, None),
("{{ x - 1 }}", STATE_UNKNOWN, STATE_UNAVAILABLE),
],
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.usefixtures("setup_state_panel")
async def test_state_template_states(hass: HomeAssistant, expected: str) -> None:
async def test_state_template_states(
hass: HomeAssistant, expected: str, trigger_expected: str, style: ConfigurationStyle
) -> None:
"""Test the state template."""
# Force a trigger
hass.states.async_set(TEST_STATE_ENTITY_ID, None)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
if trigger_expected and style == ConfigurationStyle.TRIGGER:
expected = trigger_expected
assert state.state == expected
@pytest.mark.parametrize(
("count", "state_template", "attribute_template"),
("count", "state_template", "attribute_template", "attribute"),
[
(
1,
"{{ 'disarmed' }}",
"{% if states.switch.test_state.state %}mdi:check{% endif %}",
"icon",
)
],
)
@pytest.mark.parametrize(
("style", "attribute"),
("style", "initial_state"),
[
(ConfigurationStyle.MODERN, "icon"),
(ConfigurationStyle.MODERN, ""),
(ConfigurationStyle.TRIGGER, None),
],
)
@pytest.mark.usefixtures("setup_single_attribute_state_panel")
async def test_icon_template(
hass: HomeAssistant,
) -> None:
async def test_icon_template(hass: HomeAssistant, initial_state: str) -> None:
"""Test icon template."""
state = hass.states.get(TEST_ENTITY_ID)
assert state.attributes.get("icon") in ("", None)
assert state.attributes.get("icon") == initial_state
hass.states.async_set("switch.test_state", STATE_ON)
hass.states.async_set(TEST_SWITCH, STATE_ON)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
@ -358,30 +436,30 @@ async def test_icon_template(
@pytest.mark.parametrize(
("count", "state_template", "attribute_template"),
("count", "state_template", "attribute_template", "attribute"),
[
(
1,
"{{ 'disarmed' }}",
"{% if states.switch.test_state.state %}local/panel.png{% endif %}",
"picture",
)
],
)
@pytest.mark.parametrize(
("style", "attribute"),
("style", "initial_state"),
[
(ConfigurationStyle.MODERN, "picture"),
(ConfigurationStyle.MODERN, ""),
(ConfigurationStyle.TRIGGER, None),
],
)
@pytest.mark.usefixtures("setup_single_attribute_state_panel")
async def test_picture_template(
hass: HomeAssistant,
) -> None:
async def test_picture_template(hass: HomeAssistant, initial_state: str) -> None:
"""Test icon template."""
state = hass.states.get(TEST_ENTITY_ID)
assert state.attributes.get("entity_picture") in ("", None)
assert state.attributes.get("entity_picture") == initial_state
hass.states.async_set("switch.test_state", STATE_ON)
hass.states.async_set(TEST_SWITCH, STATE_ON)
await hass.async_block_till_done()
state = hass.states.get(TEST_ENTITY_ID)
@ -425,7 +503,8 @@ async def test_setup_config_entry(
@pytest.mark.parametrize(("count", "state_template"), [(1, None)])
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.parametrize(
"panel_config", [OPTIMISTIC_TEMPLATE_ALARM_CONFIG, EMPTY_ACTIONS]
@ -459,7 +538,8 @@ async def test_optimistic_states(hass: HomeAssistant) -> None:
@pytest.mark.parametrize("count", [0])
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.parametrize(
("panel_config", "state_template", "msg"),
@ -538,11 +618,15 @@ async def test_legacy_template_syntax_error(
[
(ConfigurationStyle.LEGACY, TEST_ENTITY_ID),
(ConfigurationStyle.MODERN, "alarm_control_panel.template_alarm_panel"),
(ConfigurationStyle.TRIGGER, "alarm_control_panel.unnamed_device"),
],
)
@pytest.mark.usefixtures("setup_single_attribute_state_panel")
async def test_name(hass: HomeAssistant, test_entity_id: str) -> None:
"""Test the accessibility of the name attribute."""
hass.states.async_set(TEST_STATE_ENTITY_ID, "disarmed")
await hass.async_block_till_done()
state = hass.states.get(test_entity_id)
assert state is not None
assert state.attributes.get("friendly_name") == "Template Alarm Panel"
@ -552,7 +636,8 @@ async def test_name(hass: HomeAssistant, test_entity_id: str) -> None:
("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")]
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.parametrize(
"service",
@ -615,6 +700,21 @@ async def test_actions(
],
ConfigurationStyle.MODERN,
),
(
[
{
"name": "test_template_alarm_control_panel_01",
"state": "{{ true }}",
**UNIQUE_ID_CONFIG,
},
{
"name": "test_template_alarm_control_panel_02",
"state": "{{ false }}",
**UNIQUE_ID_CONFIG,
},
],
ConfigurationStyle.TRIGGER,
),
],
)
@pytest.mark.usefixtures("setup_panel")
@ -669,7 +769,8 @@ async def test_nested_unique_id(
@pytest.mark.parametrize(("count", "state_template"), [(1, "disarmed")])
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.parametrize(
("panel_config", "code_format", "code_arm_required"),
@ -714,7 +815,8 @@ async def test_code_config(hass: HomeAssistant, code_format, code_arm_required)
("count", "state_template"), [(1, "{{ states('alarm_control_panel.test') }}")]
)
@pytest.mark.parametrize(
"style", [ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN]
"style",
[ConfigurationStyle.LEGACY, ConfigurationStyle.MODERN, ConfigurationStyle.TRIGGER],
)
@pytest.mark.parametrize(
("restored_state", "initial_state"),