Skip to content

Commit

Permalink
Add scripts and data-templating to alerts
Browse files Browse the repository at this point in the history
  • Loading branch information
jhenkens committed Oct 23, 2023
1 parent a78e3f7 commit a7cc2ad
Show file tree
Hide file tree
Showing 3 changed files with 149 additions and 9 deletions.
66 changes: 59 additions & 7 deletions homeassistant/components/alert/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
ATTR_TITLE,
DOMAIN as DOMAIN_NOTIFY,
)
from homeassistant.components.script import DOMAIN as DOMAIN_SCRIPT
from homeassistant.const import (
CONF_ENTITY_ID,
CONF_NAME,
Expand Down Expand Up @@ -45,8 +46,10 @@
CONF_DATA,
CONF_DONE_MESSAGE,
CONF_NOTIFIERS,
CONF_SCRIPT,
CONF_SKIP_FIRST,
CONF_TITLE,
CONF_VARIABLES,
DEFAULT_CAN_ACK,
DEFAULT_SKIP_FIRST,
DOMAIN,
Expand All @@ -69,10 +72,16 @@
vol.Optional(CONF_ALERT_MESSAGE): cv.template,
vol.Optional(CONF_DONE_MESSAGE): cv.template,
vol.Optional(CONF_TITLE): cv.template,
vol.Optional(CONF_DATA): dict,
vol.Optional(CONF_DATA): vol.Any(
cv.template, vol.All(dict, cv.template_complex)
),
vol.Optional(CONF_NOTIFIERS, default=list): vol.All(
cv.ensure_list, [cv.string]
),
vol.Optional(CONF_VARIABLES): vol.Any(
cv.template, vol.All(dict, cv.template_complex)
),
vol.Optional(CONF_SCRIPT): cv.string,
}
)

Expand Down Expand Up @@ -101,7 +110,9 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
notifiers = cfg[CONF_NOTIFIERS]
can_ack = cfg[CONF_CAN_ACK]
title_template = cfg.get(CONF_TITLE)
data = cfg.get(CONF_DATA)
data: dict[str, Any] | None = cfg.get(CONF_DATA)
variables: dict[str, Any] | None = cfg.get(CONF_VARIABLES)
script: str | None = cfg.get(CONF_SCRIPT)

entities.append(
Alert(
Expand All @@ -118,6 +129,8 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
can_ack,
title_template,
data,
variables,
script,
)
)

Expand Down Expand Up @@ -152,14 +165,17 @@ def __init__(
notifiers: list[str],
can_ack: bool,
title_template: Template | None,
data: dict[Any, Any],
data: dict[str, Any] | None,
variables: dict[str, Any] | None,
script: str | None,
) -> None:
"""Initialize the alert."""
self.hass = hass
self._attr_name = name
self._alert_state = state
self._skip_first = skip_first
self._data = data
self._variables = variables

self._message_template = message_template
if self._message_template is not None:
Expand All @@ -174,6 +190,7 @@ def __init__(
self._title_template.hass = hass

self._notifiers = notifiers
self._script = script
self._can_ack = can_ack

self._delay = [timedelta(minutes=val) for val in repeat]
Expand Down Expand Up @@ -265,19 +282,52 @@ async def _notify(self, *args: Any) -> None:
message = self._attr_name

await self._send_notification_message(message)
await self._call_script("fire")
await self._schedule_notify()

async def _notify_done_message(self) -> None:
"""Send notification of complete alert."""
LOGGER.info("Alerting: %s", self._done_message_template)
self._send_done_message = False

if self._done_message_template is None:
if self._done_message_template is not None:
message = self._done_message_template.async_render(parse_result=False)
await self._send_notification_message(message)
await self._call_script("done")

def _data_template_creator(self, value: Any, **kwargs: Any) -> Any:
"""Recursive template creator helper function."""
if isinstance(value, list):
return [self._data_template_creator(item) for item in value]
if isinstance(value, dict):
return {
key: self._data_template_creator(item) for key, item in value.items()
}
if isinstance(value, Template):
value.hass = self.hass
return value.async_render(kwargs, parse_result=False)
return value

async def _call_script(self, event: str) -> None:
if not self._script:
return

message = self._done_message_template.async_render(parse_result=False)
script_variables = {"event": event}

await self._send_notification_message(message)
if self._variables:
script_variables.update(self._data_template_creator(self._variables))

LOGGER.debug(script_variables)

try:
await self.hass.services.async_call(
DOMAIN_SCRIPT, self._script, script_variables, context=self._context
)
except ServiceNotFound:
LOGGER.error(
"Failed to call script.%s, retrying at next notification interval",
self._script,
)

async def _send_notification_message(self, message: Any) -> None:
if not self._notifiers:
Expand All @@ -289,7 +339,7 @@ async def _send_notification_message(self, message: Any) -> None:
title = self._title_template.async_render(parse_result=False)
msg_payload[ATTR_TITLE] = title
if self._data:
msg_payload[ATTR_DATA] = self._data
msg_payload[ATTR_DATA] = self._data_template_creator(self._data)

LOGGER.debug(msg_payload)

Expand All @@ -309,12 +359,14 @@ async def async_turn_on(self, **kwargs: Any) -> None:
LOGGER.debug("Reset Alert: %s", self._attr_name)
self._ack = False
self.async_write_ha_state()
await self._call_script("unack")

async def async_turn_off(self, **kwargs: Any) -> None:
"""Async Acknowledge alert."""
LOGGER.debug("Acknowledged Alert: %s", self._attr_name)
self._ack = True
self.async_write_ha_state()
await self._call_script("ack")

async def async_toggle(self, **kwargs: Any) -> None:
"""Async toggle alert."""
Expand Down
2 changes: 2 additions & 0 deletions homeassistant/components/alert/const.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
CONF_DONE_MESSAGE = "done_message"
CONF_TITLE = "title"
CONF_DATA = "data"
CONF_VARIABLES = "variables"
CONF_SCRIPT = "script"

DEFAULT_CAN_ACK = True
DEFAULT_SKIP_FIRST = False
90 changes: 88 additions & 2 deletions tests/components/alert/test_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@
CONF_DATA,
CONF_DONE_MESSAGE,
CONF_NOTIFIERS,
CONF_SCRIPT,
CONF_SKIP_FIRST,
CONF_TITLE,
CONF_VARIABLES,
DOMAIN,
)
import homeassistant.components.notify as notify
import homeassistant.components.script as script
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_ENTITY_ID,
Expand All @@ -37,11 +40,19 @@
DONE_MESSAGE = "alert_gone"
NOTIFIER = "test"
BAD_NOTIFIER = "bad_notifier"
SCRIPT = "test"
BAD_SCRIPT = "bad_script"
TEMPLATE = "{{ states.sensor.test.entity_id }}"
TEST_ENTITY = "sensor.test"
TITLE = "{{ states.sensor.test.entity_id }}"
TEST_TITLE = "sensor.test"
TEST_DATA = {"data": {"inline_keyboard": ["Close garage:/close_garage"]}}
TEST_DATA = {"inline_keyboard": ["Close garage:/close_garage"], "subtitle": TITLE}
TEST_DATA_RESOLVED = {
"inline_keyboard": ["Close garage:/close_garage"],
"subtitle": TEST_TITLE,
}
TEST_VARIABLES = TEST_DATA
TEST_VARIABLES_RESOLVED = TEST_DATA_RESOLVED
TEST_CONFIG = {
DOMAIN: {
NAME: {
Expand Down Expand Up @@ -70,6 +81,8 @@
False,
None,
None,
None,
None,
]
ENTITY_ID = f"{DOMAIN}.{NAME}"

Expand All @@ -80,6 +93,12 @@ def mock_notifier(hass: HomeAssistant) -> list[ServiceCall]:
return async_mock_service(hass, notify.DOMAIN, NOTIFIER)


@pytest.fixture
def mock_script(hass: HomeAssistant) -> list[ServiceCall]:
"""Mock for script."""
return async_mock_service(hass, script.DOMAIN, SCRIPT)


async def test_setup(hass: HomeAssistant) -> None:
"""Test setup method."""
assert await async_setup_component(hass, DOMAIN, TEST_CONFIG)
Expand Down Expand Up @@ -220,6 +239,25 @@ async def test_bad_notifier(
assert hass.states.get(ENTITY_ID).state == STATE_IDLE


async def test_bad_script(hass: HomeAssistant, mock_script: list[ServiceCall]) -> None:
"""Test a broken notifier does not break the alert."""
config = deepcopy(TEST_CONFIG)
del config[DOMAIN][NAME][CONF_NOTIFIERS]
config[DOMAIN][NAME][CONF_SCRIPT] = BAD_SCRIPT
assert await async_setup_component(hass, DOMAIN, config)
assert len(mock_script) == 0

hass.states.async_set("sensor.test", STATE_ON)
await hass.async_block_till_done()
assert len(mock_script) == 0
assert hass.states.get(ENTITY_ID).state == STATE_ON

hass.states.async_set("sensor.test", STATE_OFF)
await hass.async_block_till_done()
assert len(mock_script) == 0
assert hass.states.get(ENTITY_ID).state == STATE_IDLE


async def test_no_notifiers(
hass: HomeAssistant, mock_notifier: list[ServiceCall]
) -> None:
Expand Down Expand Up @@ -321,7 +359,7 @@ async def test_sending_data_notification(
await hass.async_block_till_done()
assert len(mock_notifier) == 1
last_event = mock_notifier[-1]
assert last_event.data[notify.ATTR_DATA] == TEST_DATA
assert last_event.data[notify.ATTR_DATA] == TEST_DATA_RESOLVED


async def test_skipfirst(hass: HomeAssistant, mock_notifier: list[ServiceCall]) -> None:
Expand All @@ -345,3 +383,51 @@ async def test_done_message_state_tracker_reset_on_cancel(hass: HomeAssistant) -
hass.async_add_job(entity.end_alerting)
await hass.async_block_till_done()
assert entity._send_done_message is False


async def test_script_events(
hass: HomeAssistant, mock_script: list[ServiceCall]
) -> None:
"""Test skipping first notification."""
config = deepcopy(TEST_CONFIG)
del config[DOMAIN][NAME][CONF_NOTIFIERS]
config[DOMAIN][NAME][CONF_SCRIPT] = SCRIPT
config[DOMAIN][NAME][CONF_VARIABLES] = TEST_VARIABLES
assert await async_setup_component(hass, DOMAIN, config)
assert len(mock_script) == 0

data = deepcopy(TEST_VARIABLES_RESOLVED)

hass.states.async_set("sensor.test", STATE_ON)
await hass.async_block_till_done()
assert len(mock_script) == 1
data["event"] = "fire"
assert mock_script[-1].data == data

await hass.services.async_call(
DOMAIN,
SERVICE_TOGGLE,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
assert hass.states.get(ENTITY_ID).state == STATE_OFF
assert len(mock_script) == 2
data["event"] = "ack"
assert mock_script[-1].data == data

await hass.services.async_call(
DOMAIN,
SERVICE_TOGGLE,
{ATTR_ENTITY_ID: ENTITY_ID},
blocking=True,
)
assert hass.states.get(ENTITY_ID).state == STATE_ON
assert len(mock_script) == 3
data["event"] = "unack"
assert mock_script[-1].data == data

hass.states.async_set("sensor.test", STATE_OFF)
await hass.async_block_till_done()
assert len(mock_script) == 4
data["event"] = "done"
assert mock_script[-1].data == data

0 comments on commit a7cc2ad

Please sign in to comment.