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."""
import logging
from typing import Optional
from __future__ import annotations
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.const import EVENT_HOMEASSISTANT_START
from homeassistant.core import CoreState, callback
from homeassistant.const import EVENT_HOMEASSISTANT_START, SERVICE_RELOAD
from homeassistant.core import CoreState, Event, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.helpers import (
discovery,
trigger as trigger_helper,
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
_LOGGER = logging.getLogger(__name__)
async def async_setup(hass, config):
"""Set up the template integration."""
if DOMAIN in config:
for conf in config[DOMAIN]:
coordinator = TriggerUpdateCoordinator(hass, conf)
await coordinator.async_setup(config)
await _process_config(hass, 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
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 to handle incoming data."""
REMOVE_TRIGGER = object()
def __init__(self, hass, config):
"""Instantiate trigger data."""
super().__init__(
hass, logging.getLogger(__name__), name="Trigger Update Coordinator"
)
super().__init__(hass, _LOGGER, name="Trigger Update Coordinator")
self.config = config
self._unsub_trigger = None
self._unsub_start: Callable[[], None] | None = None
self._unsub_trigger: Callable[[], None] | None = None
@property
def unique_id(self) -> Optional[str]:
def unique_id(self) -> str | None:
"""Return unique ID for the entity."""
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):
"""Set up the trigger and create entities."""
if self.hass.state == CoreState.running:
await self._attach_triggers()
else:
self.hass.bus.async_listen_once(
self._unsub_start = self.hass.bus.async_listen_once(
EVENT_HOMEASSISTANT_START, self._attach_triggers
)
@ -65,6 +123,9 @@ class TriggerUpdateCoordinator(update_coordinator.DataUpdateCoordinator):
async def _attach_triggers(self, start_event=None) -> None:
"""Attach the triggers."""
if start_event is not None:
self._unsub_start = None
self._unsub_trigger = await trigger_helper.async_initialize_triggers(
self.hass,
self.config[CONF_TRIGGER],

View File

@ -36,7 +36,7 @@ from .const import (
)
from .sensor import SENSOR_SCHEMA as PLATFORM_SENSOR_SCHEMA
CONVERSION_PLATFORM = {
LEGACY_SENSOR = {
CONF_ICON_TEMPLATE: CONF_ICON,
CONF_ENTITY_PICTURE_TEMPLATE: CONF_PICTURE,
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.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):
"""Validate config."""
if DOMAIN not in config:
return config
trigger_entity_configs = []
config_sections = []
for cfg in cv.ensure_list(config[DOMAIN]):
try:
cfg = TRIGGER_ENTITY_SCHEMA(cfg)
cfg = CONFIG_SECTION_SCHEMA(cfg)
cfg[CONF_TRIGGER] = await async_validate_trigger_config(
hass, cfg[CONF_TRIGGER]
)
@ -88,39 +115,14 @@ async def async_validate_config(hass, config):
async_log_exception(err, DOMAIN, cfg, hass)
continue
if CONF_SENSORS not in cfg:
trigger_entity_configs.append(cfg)
continue
if CONF_TRIGGER in cfg and CONF_SENSORS in cfg:
cfg = _rewrite_legacy_to_modern_trigger_conf(cfg)
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 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)
config_sections.append(cfg)
# Create a copy of the configuration with all config for current
# component removed and add validated config back in.
config = config_without_domain(config, DOMAIN)
config[DOMAIN] = trigger_entity_configs
config[DOMAIN] = config_sections
return config

View File

@ -27,7 +27,14 @@ async def test_reloadable(hass):
"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()
@ -35,8 +42,12 @@ async def test_reloadable(hass):
await hass.async_start()
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 len(hass.states.async_all()) == 2
assert hass.states.get("sensor.top_level").state == "init"
yaml_path = path.join(
_get_fixtures_base_path(),
@ -52,11 +63,16 @@ async def test_reloadable(hass):
)
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.top_level") is None
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 hass.states.get("sensor.top_level_2").state == "reload"
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 }}"
},
},
}
},
"template": {
"trigger": {"platform": "event", "event_type": "event_1"},
"sensor": {
"name": "top level",
"state": "{{ trigger.event.data.source }}",
},
},
},
)
await hass.async_block_till_done()
@ -82,8 +105,12 @@ async def test_reloadable_can_remove(hass):
await hass.async_start()
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 len(hass.states.async_all()) == 2
assert hass.states.get("sensor.top_level").state == "init"
yaml_path = path.join(
_get_fixtures_base_path(),
@ -251,11 +278,12 @@ async def test_reloadable_multiple_platforms(hass):
)
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.watching_tv_in_master_bedroom").state == "off"
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):

View File

@ -21,3 +21,10 @@ sensor:
== "Watch TV" or state_attr("remote.alexander_master_bedroom","current_activity")
== "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 }}"