Allow reloading top-level template entities (#48733)

This commit is contained in:
Paulus Schoutsen 2021-04-06 12:10:39 -07:00 committed by GitHub
parent c4f9489d61
commit 09635678bc
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
4 changed files with 152 additions and 54 deletions

View File

@ -1,54 +1,112 @@
"""The template component.""" """The template component."""
import logging from __future__ import annotations
from typing import Optional
import asyncio
import logging
from typing import Callable
from homeassistant import config as conf_util
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.const import EVENT_HOMEASSISTANT_START from homeassistant.const import EVENT_HOMEASSISTANT_START, SERVICE_RELOAD
from homeassistant.core import CoreState, callback from homeassistant.core import CoreState, Event, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import ( from homeassistant.helpers import (
discovery, discovery,
trigger as trigger_helper, trigger as trigger_helper,
update_coordinator, update_coordinator,
) )
from homeassistant.helpers.reload import async_setup_reload_service from homeassistant.helpers.reload import async_reload_integration_platforms
from homeassistant.loader import async_get_integration
from .const import CONF_TRIGGER, DOMAIN, PLATFORMS from .const import CONF_TRIGGER, DOMAIN, PLATFORMS
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass, config): async def async_setup(hass, config):
"""Set up the template integration.""" """Set up the template integration."""
if DOMAIN in config: if DOMAIN in config:
for conf in config[DOMAIN]: await _process_config(hass, config)
coordinator = TriggerUpdateCoordinator(hass, conf)
await coordinator.async_setup(config)
await async_setup_reload_service(hass, DOMAIN, PLATFORMS) async def _reload_config(call: Event) -> None:
"""Reload top-level + platforms."""
try:
unprocessed_conf = await conf_util.async_hass_config_yaml(hass)
except HomeAssistantError as err:
_LOGGER.error(err)
return
conf = await conf_util.async_process_component_config(
hass, unprocessed_conf, await async_get_integration(hass, DOMAIN)
)
if conf is None:
return
await async_reload_integration_platforms(hass, DOMAIN, PLATFORMS)
if DOMAIN in conf:
await _process_config(hass, conf)
hass.bus.async_fire(f"event_{DOMAIN}_reloaded", context=call.context)
hass.helpers.service.async_register_admin_service(
DOMAIN, SERVICE_RELOAD, _reload_config
)
return True return True
async def _process_config(hass, config):
"""Process config."""
coordinators: list[TriggerUpdateCoordinator] | None = hass.data.get(DOMAIN)
# Remove old ones
if coordinators:
for coordinator in coordinators:
coordinator.async_remove()
async def init_coordinator(hass, conf):
coordinator = TriggerUpdateCoordinator(hass, conf)
await coordinator.async_setup(conf)
return coordinator
hass.data[DOMAIN] = await asyncio.gather(
*[init_coordinator(hass, conf) for conf in config[DOMAIN]]
)
class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator): class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator):
"""Class to handle incoming data.""" """Class to handle incoming data."""
REMOVE_TRIGGER = object()
def __init__(self, hass, config): def __init__(self, hass, config):
"""Instantiate trigger data.""" """Instantiate trigger data."""
super().__init__( super().__init__(hass, _LOGGER, name="Trigger Update Coordinator")
hass, logging.getLogger(__name__), name="Trigger Update Coordinator"
)
self.config = config self.config = config
self._unsub_trigger = None self._unsub_start: Callable[[], None] | None = None
self._unsub_trigger: Callable[[], None] | None = None
@property @property
def unique_id(self) -> Optional[str]: def unique_id(self) -> str | None:
"""Return unique ID for the entity.""" """Return unique ID for the entity."""
return self.config.get("unique_id") return self.config.get("unique_id")
@callback
def async_remove(self):
"""Signal that the entities need to remove themselves."""
if self._unsub_start:
self._unsub_start()
if self._unsub_trigger:
self._unsub_trigger()
async def async_setup(self, hass_config): async def async_setup(self, hass_config):
"""Set up the trigger and create entities.""" """Set up the trigger and create entities."""
if self.hass.state == CoreState.running: if self.hass.state == CoreState.running:
await self._attach_triggers() await self._attach_triggers()
else: else:
self.hass.bus.async_listen_once( self._unsub_start = self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_START, self._attach_triggers EVENT_HOMEASSISTANT_START, self._attach_triggers
) )
@ -65,6 +123,9 @@ class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator):
async def _attach_triggers(self, start_event=None) -> None: async def _attach_triggers(self, start_event=None) -> None:
"""Attach the triggers.""" """Attach the triggers."""
if start_event is not None:
self._unsub_start = None
self._unsub_trigger = await trigger_helper.async_initialize_triggers( self._unsub_trigger = await trigger_helper.async_initialize_triggers(
self.hass, self.hass,
self.config[CONF_TRIGGER], self.config[CONF_TRIGGER],

View File

@ -36,7 +36,7 @@ from .const import (
) )
from .sensor import SENSOR_SCHEMA as PLATFORM_SENSOR_SCHEMA from .sensor import SENSOR_SCHEMA as PLATFORM_SENSOR_SCHEMA
CONVERSION_PLATFORM = { LEGACY_SENSOR = {
CONF_ICON_TEMPLATE: CONF_ICON, CONF_ICON_TEMPLATE: CONF_ICON,
CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE, CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE,
CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY, CONF_AVAILABILITY_TEMPLATE: CONF_AVAILABILITY,
@ -61,7 +61,7 @@ SENSOR_SCHEMA = vol.Schema(
} }
) )
TRIGGER_ENTITY_SCHEMA = vol.Schema( CONFIG_SECTION_SCHEMA = vol.Schema(
{ {
vol.Optional(CONF_UNIQUE_ID): cv.string, vol.Optional(CONF_UNIQUE_ID): cv.string,
vol.Required(CONF_TRIGGER): cv.TRIGGER_SCHEMA, vol.Required(CONF_TRIGGER): cv.TRIGGER_SCHEMA,
@ -71,16 +71,43 @@ TRIGGER_ENTITY_SCHEMA = vol.Schema(
) )
def _rewrite_legacy_to_modern_trigger_conf(cfg: dict):
"""Rewrite a legacy to a modern trigger-basd conf."""
logging.getLogger(__name__).warning(
"The entity definition format under template: differs from the platform configuration format. See https://www.home-assistant.io/integrations/template#configuration-for-trigger-based-template-sensors"
)
sensor = list(cfg[SENSOR_DOMAIN]) if SENSOR_DOMAIN in cfg else []
for device_id, entity_cfg in cfg[CONF_SENSORS].items():
entity_cfg = {**entity_cfg}
for from_key, to_key in LEGACY_SENSOR.items():
if from_key not in entity_cfg or to_key in entity_cfg:
continue
val = entity_cfg.pop(from_key)
if isinstance(val, str):
val = template.Template(val)
entity_cfg[to_key] = val
if CONF_NAME not in entity_cfg:
entity_cfg[CONF_NAME] = template.Template(device_id)
sensor.append(entity_cfg)
return {**cfg, "sensor": sensor}
async def async_validate_config(hass, config): async def async_validate_config(hass, config):
"""Validate config.""" """Validate config."""
if DOMAIN not in config: if DOMAIN not in config:
return config return config
trigger_entity_configs = [] config_sections = []
for cfg in cv.ensure_list(config[DOMAIN]): for cfg in cv.ensure_list(config[DOMAIN]):
try: try:
cfg = TRIGGER_ENTITY_SCHEMA(cfg) cfg = CONFIG_SECTION_SCHEMA(cfg)
cfg[CONF_TRIGGER] = await async_validate_trigger_config( cfg[CONF_TRIGGER] = await async_validate_trigger_config(
hass, cfg[CONF_TRIGGER] hass, cfg[CONF_TRIGGER]
) )
@ -88,39 +115,14 @@ async def async_validate_config(hass, config):
async_log_exception(err, DOMAIN, cfg, hass) async_log_exception(err, DOMAIN, cfg, hass)
continue continue
if CONF_SENSORS not in cfg: if CONF_TRIGGER in cfg and CONF_SENSORS in cfg:
trigger_entity_configs.append(cfg) cfg = _rewrite_legacy_to_modern_trigger_conf(cfg)
continue
logging.getLogger(__name__).warning( config_sections.append(cfg)
"The entity definition format under template: differs from the platform configuration format. See https://www.home-assistant.io/integrations/template#configuration-for-trigger-based-template-sensors"
)
sensor = list(cfg[SENSOR_DOMAIN]) if SENSOR_DOMAIN in cfg else []
for device_id, entity_cfg in cfg[CONF_SENSORS].items():
entity_cfg = {**entity_cfg}
for from_key, to_key in CONVERSION_PLATFORM.items():
if from_key not in entity_cfg or to_key in entity_cfg:
continue
val = entity_cfg.pop(from_key)
if isinstance(val, str):
val = template.Template(val)
entity_cfg[to_key] = val
if CONF_NAME not in entity_cfg:
entity_cfg[CONF_NAME] = template.Template(device_id)
sensor.append(entity_cfg)
cfg = {**cfg, "sensor": sensor}
trigger_entity_configs.append(cfg)
# Create a copy of the configuration with all config for current # Create a copy of the configuration with all config for current
# component removed and add validated config back in. # component removed and add validated config back in.
config = config_without_domain(config, DOMAIN) config = config_without_domain(config, DOMAIN)
config[DOMAIN] = trigger_entity_configs config[DOMAIN] = config_sections
return config return config

View File

@ -27,7 +27,14 @@ async def test_reloadable(hass):
"value_template": "{{ states.sensor.test_sensor.state }}" "value_template": "{{ states.sensor.test_sensor.state }}"
}, },
}, },
} },
"template": {
"trigger": {"platform": "event", "event_type": "event_1"},
"sensor": {
"name": "top level",
"state": "{{ trigger.event.data.source }}",
},
},
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -35,8 +42,12 @@ async def test_reloadable(hass):
await hass.async_start() await hass.async_start()
await hass.async_block_till_done() await hass.async_block_till_done()
hass.bus.async_fire("event_1", {"source": "init"})
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 3
assert hass.states.get("sensor.state").state == "mytest" assert hass.states.get("sensor.state").state == "mytest"
assert len(hass.states.async_all()) == 2 assert hass.states.get("sensor.top_level").state == "init"
yaml_path = path.join( yaml_path = path.join(
_get_fixtures_base_path(), _get_fixtures_base_path(),
@ -52,11 +63,16 @@ async def test_reloadable(hass):
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(hass.states.async_all()) == 3 assert len(hass.states.async_all()) == 4
hass.bus.async_fire("event_2", {"source": "reload"})
await hass.async_block_till_done()
assert hass.states.get("sensor.state") is None assert hass.states.get("sensor.state") is None
assert hass.states.get("sensor.top_level") is None
assert hass.states.get("sensor.watching_tv_in_master_bedroom").state == "off" assert hass.states.get("sensor.watching_tv_in_master_bedroom").state == "off"
assert float(hass.states.get("sensor.combined_sensor_energy_usage").state) == 0 assert float(hass.states.get("sensor.combined_sensor_energy_usage").state) == 0
assert hass.states.get("sensor.top_level_2").state == "reload"
async def test_reloadable_can_remove(hass): async def test_reloadable_can_remove(hass):
@ -74,7 +90,14 @@ async def test_reloadable_can_remove(hass):
"value_template": "{{ states.sensor.test_sensor.state }}" "value_template": "{{ states.sensor.test_sensor.state }}"
}, },
}, },
} },
"template": {
"trigger": {"platform": "event", "event_type": "event_1"},
"sensor": {
"name": "top level",
"state": "{{ trigger.event.data.source }}",
},
},
}, },
) )
await hass.async_block_till_done() await hass.async_block_till_done()
@ -82,8 +105,12 @@ async def test_reloadable_can_remove(hass):
await hass.async_start() await hass.async_start()
await hass.async_block_till_done() await hass.async_block_till_done()
hass.bus.async_fire("event_1", {"source": "init"})
await hass.async_block_till_done()
assert len(hass.states.async_all()) == 3
assert hass.states.get("sensor.state").state == "mytest" assert hass.states.get("sensor.state").state == "mytest"
assert len(hass.states.async_all()) == 2 assert hass.states.get("sensor.top_level").state == "init"
yaml_path = path.join( yaml_path = path.join(
_get_fixtures_base_path(), _get_fixtures_base_path(),
@ -251,11 +278,12 @@ async def test_reloadable_multiple_platforms(hass):
) )
await hass.async_block_till_done() await hass.async_block_till_done()
assert len(hass.states.async_all()) == 3 assert len(hass.states.async_all()) == 4
assert hass.states.get("sensor.state") is None assert hass.states.get("sensor.state") is None
assert hass.states.get("sensor.watching_tv_in_master_bedroom").state == "off" assert hass.states.get("sensor.watching_tv_in_master_bedroom").state == "off"
assert float(hass.states.get("sensor.combined_sensor_energy_usage").state) == 0 assert float(hass.states.get("sensor.combined_sensor_energy_usage").state) == 0
assert hass.states.get("sensor.top_level_2") is not None
async def test_reload_sensors_that_reference_other_template_sensors(hass): async def test_reload_sensors_that_reference_other_template_sensors(hass):

View File

@ -21,3 +21,10 @@ sensor:
== "Watch TV" or state_attr("remote.alexander_master_bedroom","current_activity") == "Watch TV" or state_attr("remote.alexander_master_bedroom","current_activity")
== "Watch Apple TV" %}on{% else %}off{% endif %}' == "Watch Apple TV" %}on{% else %}off{% endif %}'
template:
trigger:
platform: event
event_type: event_2
sensor:
name: top level 2
state: "{{ trigger.event.data.source }}"