mirror of
https://github.com/home-assistant/core.git
synced 2025-11-13 04:50:17 +00:00
Compare commits
3 Commits
synesthesi
...
cursor/add
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b2fe77b7f5 | ||
|
|
d984e4398e | ||
|
|
75bd1a0310 |
@@ -1,7 +1,9 @@
|
|||||||
"""The blueprint integration."""
|
"""The blueprint integration."""
|
||||||
|
|
||||||
|
from homeassistant.const import Platform
|
||||||
from homeassistant.core import HomeAssistant
|
from homeassistant.core import HomeAssistant
|
||||||
from homeassistant.helpers import config_validation as cv
|
from homeassistant.helpers import config_validation as cv
|
||||||
|
from homeassistant.helpers.discovery import async_load_platform
|
||||||
from homeassistant.helpers.typing import ConfigType
|
from homeassistant.helpers.typing import ConfigType
|
||||||
|
|
||||||
from . import websocket_api
|
from . import websocket_api
|
||||||
@@ -28,4 +30,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
|||||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||||
"""Set up the blueprint integration."""
|
"""Set up the blueprint integration."""
|
||||||
websocket_api.async_setup(hass)
|
websocket_api.async_setup(hass)
|
||||||
|
hass.async_create_task(
|
||||||
|
async_load_platform(hass, Platform.UPDATE, DOMAIN, None, config)
|
||||||
|
)
|
||||||
return True
|
return True
|
||||||
|
|||||||
@@ -204,8 +204,8 @@ class DomainBlueprints:
|
|||||||
self.hass = hass
|
self.hass = hass
|
||||||
self.domain = domain
|
self.domain = domain
|
||||||
self.logger = logger
|
self.logger = logger
|
||||||
self._blueprint_in_use = blueprint_in_use
|
self.blueprint_in_use = blueprint_in_use
|
||||||
self._reload_blueprint_consumers = reload_blueprint_consumers
|
self.reload_blueprint_consumers = reload_blueprint_consumers
|
||||||
self._blueprints: dict[str, Blueprint | None] = {}
|
self._blueprints: dict[str, Blueprint | None] = {}
|
||||||
self._load_lock = asyncio.Lock()
|
self._load_lock = asyncio.Lock()
|
||||||
self._blueprint_schema = blueprint_schema
|
self._blueprint_schema = blueprint_schema
|
||||||
@@ -325,7 +325,7 @@ class DomainBlueprints:
|
|||||||
|
|
||||||
async def async_remove_blueprint(self, blueprint_path: str) -> None:
|
async def async_remove_blueprint(self, blueprint_path: str) -> None:
|
||||||
"""Remove a blueprint file."""
|
"""Remove a blueprint file."""
|
||||||
if self._blueprint_in_use(self.hass, blueprint_path):
|
if self.blueprint_in_use(self.hass, blueprint_path):
|
||||||
raise BlueprintInUse(self.domain, blueprint_path)
|
raise BlueprintInUse(self.domain, blueprint_path)
|
||||||
path = self.blueprint_folder / blueprint_path
|
path = self.blueprint_folder / blueprint_path
|
||||||
await self.hass.async_add_executor_job(path.unlink)
|
await self.hass.async_add_executor_job(path.unlink)
|
||||||
@@ -362,7 +362,7 @@ class DomainBlueprints:
|
|||||||
self._blueprints[blueprint_path] = blueprint
|
self._blueprints[blueprint_path] = blueprint
|
||||||
|
|
||||||
if overrides_existing:
|
if overrides_existing:
|
||||||
await self._reload_blueprint_consumers(self.hass, blueprint_path)
|
await self.reload_blueprint_consumers(self.hass, blueprint_path)
|
||||||
|
|
||||||
return overrides_existing
|
return overrides_existing
|
||||||
|
|
||||||
|
|||||||
293
homeassistant/components/blueprint/update.py
Normal file
293
homeassistant/components/blueprint/update.py
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
"""Update entities for blueprints."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
from dataclasses import dataclass
|
||||||
|
import logging
|
||||||
|
from datetime import timedelta
|
||||||
|
from typing import Any, Final
|
||||||
|
|
||||||
|
from homeassistant.components import automation, script
|
||||||
|
from . import importer, models
|
||||||
|
from homeassistant.components.update import UpdateEntity, UpdateEntityFeature
|
||||||
|
from homeassistant.const import CONF_SOURCE_URL
|
||||||
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||||
|
from homeassistant.exceptions import HomeAssistantError
|
||||||
|
from homeassistant.helpers import event as event_helper
|
||||||
|
from homeassistant.helpers.entity import EntityCategory
|
||||||
|
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||||
|
from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType
|
||||||
|
|
||||||
|
from .const import DOMAIN as BLUEPRINT_DOMAIN
|
||||||
|
from .errors import BlueprintException
|
||||||
|
|
||||||
|
_LOGGER = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_LATEST_VERSION_PLACEHOLDER: Final = "remote"
|
||||||
|
DATA_UPDATE_MANAGER: Final = "update_manager"
|
||||||
|
REFRESH_INTERVAL: Final = timedelta(days=1)
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(slots=True)
|
||||||
|
class BlueprintUsage:
|
||||||
|
"""Details about a blueprint currently in use."""
|
||||||
|
|
||||||
|
domain: str
|
||||||
|
path: str
|
||||||
|
domain_blueprints: models.DomainBlueprints
|
||||||
|
blueprint: models.Blueprint
|
||||||
|
entities: list[str]
|
||||||
|
|
||||||
|
|
||||||
|
async def async_setup_platform(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
config: ConfigType,
|
||||||
|
async_add_entities: AddEntitiesCallback,
|
||||||
|
discovery_info: DiscoveryInfoType | None = None,
|
||||||
|
) -> None:
|
||||||
|
"""Set up the blueprint update platform."""
|
||||||
|
data = hass.data.setdefault(BLUEPRINT_DOMAIN, {})
|
||||||
|
|
||||||
|
if (manager := data.get(DATA_UPDATE_MANAGER)) is None:
|
||||||
|
manager = BlueprintUpdateManager(hass, async_add_entities)
|
||||||
|
data[DATA_UPDATE_MANAGER] = manager
|
||||||
|
await manager.async_start()
|
||||||
|
return
|
||||||
|
|
||||||
|
manager.replace_add_entities(async_add_entities)
|
||||||
|
await manager.async_recreate_entities()
|
||||||
|
|
||||||
|
|
||||||
|
class BlueprintUpdateManager:
|
||||||
|
"""Manage blueprint update entities based on blueprint usage."""
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self, hass: HomeAssistant, async_add_entities: AddEntitiesCallback
|
||||||
|
) -> None:
|
||||||
|
"""Initialize the manager."""
|
||||||
|
self.hass = hass
|
||||||
|
self._async_add_entities = async_add_entities
|
||||||
|
self._entities: dict[tuple[str, str], BlueprintUpdateEntity] = {}
|
||||||
|
self._lock = asyncio.Lock()
|
||||||
|
self._refresh_cancel: CALLBACK_TYPE | None = None
|
||||||
|
self._started = False
|
||||||
|
self._interval_unsub: CALLBACK_TYPE | None = None
|
||||||
|
|
||||||
|
async def async_start(self) -> None:
|
||||||
|
"""Start tracking blueprint usage."""
|
||||||
|
if self._started:
|
||||||
|
return
|
||||||
|
self._started = True
|
||||||
|
|
||||||
|
self._interval_unsub = event_helper.async_track_time_interval(
|
||||||
|
self.hass, self._handle_time_interval, REFRESH_INTERVAL
|
||||||
|
)
|
||||||
|
await self.async_refresh_entities()
|
||||||
|
|
||||||
|
def replace_add_entities(self, async_add_entities: AddEntitiesCallback) -> None:
|
||||||
|
"""Update the callback used to register entities."""
|
||||||
|
self._async_add_entities = async_add_entities
|
||||||
|
|
||||||
|
async def async_recreate_entities(self) -> None:
|
||||||
|
"""Recreate entities after the platform has been reloaded."""
|
||||||
|
async with self._lock:
|
||||||
|
entities = list(self._entities.values())
|
||||||
|
self._entities.clear()
|
||||||
|
|
||||||
|
for entity in entities:
|
||||||
|
await entity.async_remove()
|
||||||
|
|
||||||
|
await self.async_refresh_entities()
|
||||||
|
|
||||||
|
async def async_refresh_entities(self) -> None:
|
||||||
|
"""Refresh update entities based on current blueprint usage."""
|
||||||
|
async with self._lock:
|
||||||
|
usage_map = await self._async_collect_in_use_blueprints()
|
||||||
|
|
||||||
|
current_keys = set(self._entities)
|
||||||
|
new_keys = set(usage_map)
|
||||||
|
|
||||||
|
for key in current_keys - new_keys:
|
||||||
|
entity = self._entities.pop(key)
|
||||||
|
await entity.async_remove()
|
||||||
|
|
||||||
|
new_entities: list[BlueprintUpdateEntity] = []
|
||||||
|
|
||||||
|
for key in new_keys - current_keys:
|
||||||
|
usage = usage_map[key]
|
||||||
|
entity = BlueprintUpdateEntity(self, usage)
|
||||||
|
self._entities[key] = entity
|
||||||
|
new_entities.append(entity)
|
||||||
|
|
||||||
|
for key in new_keys & current_keys:
|
||||||
|
self._entities[key].update_usage(usage_map[key])
|
||||||
|
self._entities[key].async_write_ha_state()
|
||||||
|
|
||||||
|
if new_entities:
|
||||||
|
self._async_add_entities(new_entities)
|
||||||
|
|
||||||
|
def async_schedule_refresh(self) -> None:
|
||||||
|
"""Schedule an asynchronous refresh."""
|
||||||
|
if self._refresh_cancel is not None:
|
||||||
|
return
|
||||||
|
|
||||||
|
self._refresh_cancel = event_helper.async_call_later(
|
||||||
|
self.hass, 0, self._handle_scheduled_refresh
|
||||||
|
)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_scheduled_refresh(self, _now: Any) -> None:
|
||||||
|
"""Run a scheduled refresh task."""
|
||||||
|
self._refresh_cancel = None
|
||||||
|
self.hass.async_create_task(self.async_refresh_entities())
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def _handle_time_interval(self, _now: Any) -> None:
|
||||||
|
"""Handle scheduled interval refresh."""
|
||||||
|
self.async_schedule_refresh()
|
||||||
|
|
||||||
|
async def _async_collect_in_use_blueprints(self) -> dict[tuple[str, str], BlueprintUsage]:
|
||||||
|
"""Collect blueprint usage information for automations and scripts."""
|
||||||
|
|
||||||
|
usage_keys: set[tuple[str, str]] = set()
|
||||||
|
|
||||||
|
if automation.DATA_COMPONENT in self.hass.data:
|
||||||
|
component = self.hass.data[automation.DATA_COMPONENT]
|
||||||
|
for automation_entity in list(component.entities):
|
||||||
|
if (path := getattr(automation_entity, "referenced_blueprint", None)):
|
||||||
|
usage_keys.add((automation.DOMAIN, path))
|
||||||
|
|
||||||
|
if script.DOMAIN in self.hass.data:
|
||||||
|
component = self.hass.data[script.DOMAIN]
|
||||||
|
for script_entity in list(component.entities):
|
||||||
|
if (path := getattr(script_entity, "referenced_blueprint", None)):
|
||||||
|
usage_keys.add((script.DOMAIN, path))
|
||||||
|
|
||||||
|
domain_blueprints_map = self.hass.data.get(BLUEPRINT_DOMAIN, {})
|
||||||
|
usage_map: dict[tuple[str, str], BlueprintUsage] = {}
|
||||||
|
|
||||||
|
for domain, path in usage_keys:
|
||||||
|
domain_blueprints: models.DomainBlueprints | None = domain_blueprints_map.get(
|
||||||
|
domain
|
||||||
|
)
|
||||||
|
|
||||||
|
if domain_blueprints is None:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if not domain_blueprints.blueprint_in_use(self.hass, path):
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
blueprint = await domain_blueprints.async_get_blueprint(path)
|
||||||
|
except BlueprintException:
|
||||||
|
continue
|
||||||
|
|
||||||
|
source_url = blueprint.metadata.get(CONF_SOURCE_URL)
|
||||||
|
if not source_url:
|
||||||
|
continue
|
||||||
|
|
||||||
|
if domain == automation.DOMAIN:
|
||||||
|
entities = automation.automations_with_blueprint(self.hass, path)
|
||||||
|
elif domain == script.DOMAIN:
|
||||||
|
entities = script.scripts_with_blueprint(self.hass, path)
|
||||||
|
else:
|
||||||
|
entities = []
|
||||||
|
|
||||||
|
usage_map[(domain, path)] = BlueprintUsage(
|
||||||
|
domain=domain,
|
||||||
|
path=path,
|
||||||
|
domain_blueprints=domain_blueprints,
|
||||||
|
blueprint=blueprint,
|
||||||
|
entities=entities,
|
||||||
|
)
|
||||||
|
|
||||||
|
return usage_map
|
||||||
|
|
||||||
|
|
||||||
|
class BlueprintUpdateEntity(UpdateEntity):
|
||||||
|
"""Define a blueprint update entity."""
|
||||||
|
|
||||||
|
_attr_entity_category = EntityCategory.CONFIG
|
||||||
|
_attr_has_entity_name = True
|
||||||
|
_attr_should_poll = False
|
||||||
|
_attr_supported_features = UpdateEntityFeature.INSTALL
|
||||||
|
|
||||||
|
def __init__(self, manager: BlueprintUpdateManager, usage: BlueprintUsage) -> None:
|
||||||
|
"""Initialize the update entity."""
|
||||||
|
self._manager = manager
|
||||||
|
self._domain = usage.domain
|
||||||
|
self._path = usage.path
|
||||||
|
self._domain_blueprints = usage.domain_blueprints
|
||||||
|
self._blueprint = usage.blueprint
|
||||||
|
self._entities_in_use = usage.entities
|
||||||
|
self._source_url = usage.blueprint.metadata.get(CONF_SOURCE_URL)
|
||||||
|
self._attr_unique_id = f"{self._domain}:{self._path}"
|
||||||
|
self._attr_in_progress = False
|
||||||
|
|
||||||
|
self.update_usage(usage)
|
||||||
|
|
||||||
|
@callback
|
||||||
|
def update_usage(self, usage: BlueprintUsage) -> None:
|
||||||
|
"""Update the entity with latest usage information."""
|
||||||
|
self._domain_blueprints = usage.domain_blueprints
|
||||||
|
self._blueprint = usage.blueprint
|
||||||
|
self._entities_in_use = usage.entities
|
||||||
|
self._source_url = usage.blueprint.metadata.get(CONF_SOURCE_URL)
|
||||||
|
|
||||||
|
self._attr_name = usage.blueprint.name
|
||||||
|
self._attr_release_summary = usage.blueprint.metadata.get("description")
|
||||||
|
self._attr_installed_version = usage.blueprint.metadata.get("version")
|
||||||
|
self._attr_release_url = self._source_url
|
||||||
|
self._attr_available = self._source_url is not None
|
||||||
|
self._attr_latest_version = (
|
||||||
|
_LATEST_VERSION_PLACEHOLDER
|
||||||
|
if self._source_url is not None
|
||||||
|
else self._attr_installed_version
|
||||||
|
)
|
||||||
|
|
||||||
|
async def async_install(self, version: str | None, backup: bool) -> None:
|
||||||
|
"""Install (refresh) the blueprint from its source."""
|
||||||
|
if self._source_url is None:
|
||||||
|
raise HomeAssistantError("Blueprint does not define a source URL")
|
||||||
|
|
||||||
|
self._attr_in_progress = True
|
||||||
|
self.async_write_ha_state()
|
||||||
|
usage: BlueprintUsage | None = None
|
||||||
|
|
||||||
|
try:
|
||||||
|
imported = await importer.fetch_blueprint_from_url(
|
||||||
|
self.hass, self._source_url
|
||||||
|
)
|
||||||
|
blueprint = imported.blueprint
|
||||||
|
|
||||||
|
if blueprint.domain != self._domain:
|
||||||
|
raise HomeAssistantError(
|
||||||
|
"Downloaded blueprint domain does not match the existing blueprint"
|
||||||
|
)
|
||||||
|
|
||||||
|
await self._domain_blueprints.async_add_blueprint(
|
||||||
|
blueprint, self._path, allow_override=True
|
||||||
|
)
|
||||||
|
|
||||||
|
usage = BlueprintUsage(
|
||||||
|
domain=self._domain,
|
||||||
|
path=self._path,
|
||||||
|
domain_blueprints=self._domain_blueprints,
|
||||||
|
blueprint=blueprint,
|
||||||
|
entities=self._entities_in_use,
|
||||||
|
)
|
||||||
|
|
||||||
|
except HomeAssistantError:
|
||||||
|
raise
|
||||||
|
except Exception as err: # noqa: BLE001 - Provide context for unexpected errors
|
||||||
|
raise HomeAssistantError("Failed to update blueprint from source") from err
|
||||||
|
finally:
|
||||||
|
self._attr_in_progress = False
|
||||||
|
|
||||||
|
if usage is not None:
|
||||||
|
self.update_usage(usage)
|
||||||
|
|
||||||
|
self.async_write_ha_state()
|
||||||
|
|
||||||
|
self._manager.async_schedule_refresh()
|
||||||
@@ -74,6 +74,7 @@ from .const import (
|
|||||||
CONF_TRACE,
|
CONF_TRACE,
|
||||||
DOMAIN,
|
DOMAIN,
|
||||||
ENTITY_ID_FORMAT,
|
ENTITY_ID_FORMAT,
|
||||||
|
EVENT_SCRIPT_RELOADED,
|
||||||
EVENT_SCRIPT_STARTED,
|
EVENT_SCRIPT_STARTED,
|
||||||
LOGGER,
|
LOGGER,
|
||||||
)
|
)
|
||||||
@@ -237,6 +238,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
|||||||
if (conf := await component.async_prepare_reload(skip_reset=True)) is None:
|
if (conf := await component.async_prepare_reload(skip_reset=True)) is None:
|
||||||
return
|
return
|
||||||
await _async_process_config(hass, conf, component)
|
await _async_process_config(hass, conf, component)
|
||||||
|
hass.bus.async_fire(EVENT_SCRIPT_RELOADED, context=service.context)
|
||||||
|
|
||||||
async def turn_on_service(service: ServiceCall) -> None:
|
async def turn_on_service(service: ServiceCall) -> None:
|
||||||
"""Call a service to turn script on."""
|
"""Call a service to turn script on."""
|
||||||
|
|||||||
@@ -17,5 +17,6 @@ CONF_TRACE = "trace"
|
|||||||
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
ENTITY_ID_FORMAT = DOMAIN + ".{}"
|
||||||
|
|
||||||
EVENT_SCRIPT_STARTED = "script_started"
|
EVENT_SCRIPT_STARTED = "script_started"
|
||||||
|
EVENT_SCRIPT_RELOADED = "script_reloaded"
|
||||||
|
|
||||||
LOGGER = logging.getLogger(__package__)
|
LOGGER = logging.getLogger(__package__)
|
||||||
|
|||||||
257
tests/components/blueprint/test_update.py
Normal file
257
tests/components/blueprint/test_update.py
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
"""Tests for blueprint update entities."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from homeassistant.components import automation, script
|
||||||
|
from homeassistant.components.blueprint import importer, models
|
||||||
|
from homeassistant.components.blueprint.const import DOMAIN as BLUEPRINT_DOMAIN
|
||||||
|
from homeassistant.components.blueprint.schemas import BLUEPRINT_SCHEMA
|
||||||
|
from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN
|
||||||
|
from homeassistant.helpers import entity_registry as er
|
||||||
|
from homeassistant.setup import async_setup_component
|
||||||
|
from homeassistant.util import yaml as yaml_util
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
async def setup_blueprint_component(hass):
|
||||||
|
"""Ensure the blueprint integration is set up for each test."""
|
||||||
|
assert await async_setup_component(hass, BLUEPRINT_DOMAIN, {})
|
||||||
|
|
||||||
|
|
||||||
|
async def test_automation_blueprint_update_entity_reloads(hass) -> None:
|
||||||
|
"""Test that updating an automation blueprint refreshes YAML and reloads automations."""
|
||||||
|
source_url = "https://example.com/blueprints/automation/test.yaml"
|
||||||
|
blueprint_rel_path = "test_namespace/test_automation.yaml"
|
||||||
|
blueprint_file = Path(
|
||||||
|
hass.config.path("blueprints", automation.DOMAIN, blueprint_rel_path)
|
||||||
|
)
|
||||||
|
blueprint_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
initial_yaml = (
|
||||||
|
"blueprint:\n"
|
||||||
|
" name: Test automation blueprint\n"
|
||||||
|
" description: Initial version\n"
|
||||||
|
" domain: automation\n"
|
||||||
|
f" source_url: {source_url}\n"
|
||||||
|
" input: {}\n"
|
||||||
|
"trigger:\n"
|
||||||
|
" - platform: event\n"
|
||||||
|
" event_type: test_event\n"
|
||||||
|
"action:\n"
|
||||||
|
" - service: test.initial_service\n"
|
||||||
|
)
|
||||||
|
blueprint_file.write_text(initial_yaml, encoding="utf-8")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.automation.helpers._reload_blueprint_automations",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_reload:
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass,
|
||||||
|
automation.DOMAIN,
|
||||||
|
{
|
||||||
|
automation.DOMAIN: {
|
||||||
|
"use_blueprint": {
|
||||||
|
"path": blueprint_rel_path,
|
||||||
|
"input": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
entity_registry = er.async_get(hass)
|
||||||
|
unique_id = f"{automation.DOMAIN}:{blueprint_rel_path}"
|
||||||
|
entity_id = entity_registry.async_get_entity_id(
|
||||||
|
UPDATE_DOMAIN, BLUEPRINT_DOMAIN, unique_id
|
||||||
|
)
|
||||||
|
assert entity_id is not None
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == "on"
|
||||||
|
assert state.attributes.get("release_url") == source_url
|
||||||
|
|
||||||
|
updated_yaml = (
|
||||||
|
"blueprint:\n"
|
||||||
|
" name: Test automation blueprint\n"
|
||||||
|
" description: Updated version\n"
|
||||||
|
" domain: automation\n"
|
||||||
|
f" source_url: {source_url}\n"
|
||||||
|
" input: {}\n"
|
||||||
|
"trigger:\n"
|
||||||
|
" - platform: event\n"
|
||||||
|
" event_type: test_event\n"
|
||||||
|
"action:\n"
|
||||||
|
" - service: test.new_service\n"
|
||||||
|
)
|
||||||
|
updated_data = yaml_util.parse_yaml(updated_yaml)
|
||||||
|
new_blueprint = models.Blueprint(
|
||||||
|
updated_data,
|
||||||
|
expected_domain=automation.DOMAIN,
|
||||||
|
path=blueprint_rel_path,
|
||||||
|
schema=automation.config.AUTOMATION_BLUEPRINT_SCHEMA,
|
||||||
|
)
|
||||||
|
imported_blueprint = importer.ImportedBlueprint(
|
||||||
|
blueprint_rel_path.removesuffix(".yaml"), updated_yaml, new_blueprint
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.blueprint.importer.fetch_blueprint_from_url",
|
||||||
|
new=AsyncMock(return_value=imported_blueprint),
|
||||||
|
) as mock_fetch:
|
||||||
|
await hass.services.async_call(
|
||||||
|
UPDATE_DOMAIN,
|
||||||
|
"install",
|
||||||
|
{"entity_id": entity_id},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
mock_fetch.assert_awaited_once_with(hass, source_url)
|
||||||
|
mock_reload.assert_awaited_once_with(hass, blueprint_rel_path)
|
||||||
|
assert "test.new_service" in blueprint_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_script_blueprint_update_entity_reloads(hass) -> None:
|
||||||
|
"""Test that updating a script blueprint refreshes YAML and reloads scripts."""
|
||||||
|
source_url = "https://example.com/blueprints/script/test.yaml"
|
||||||
|
blueprint_rel_path = "test_namespace/test_script.yaml"
|
||||||
|
blueprint_file = Path(
|
||||||
|
hass.config.path("blueprints", script.DOMAIN, blueprint_rel_path)
|
||||||
|
)
|
||||||
|
blueprint_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
initial_yaml = (
|
||||||
|
"blueprint:\n"
|
||||||
|
" name: Test script blueprint\n"
|
||||||
|
" description: Initial version\n"
|
||||||
|
" domain: script\n"
|
||||||
|
f" source_url: {source_url}\n"
|
||||||
|
" input: {}\n"
|
||||||
|
"sequence:\n"
|
||||||
|
" - event: test_event\n"
|
||||||
|
)
|
||||||
|
blueprint_file.write_text(initial_yaml, encoding="utf-8")
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.script.helpers._reload_blueprint_scripts",
|
||||||
|
new_callable=AsyncMock,
|
||||||
|
) as mock_reload:
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass,
|
||||||
|
script.DOMAIN,
|
||||||
|
{
|
||||||
|
script.DOMAIN: {
|
||||||
|
"test_script": {
|
||||||
|
"use_blueprint": {
|
||||||
|
"path": blueprint_rel_path,
|
||||||
|
"input": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
entity_registry = er.async_get(hass)
|
||||||
|
unique_id = f"{script.DOMAIN}:{blueprint_rel_path}"
|
||||||
|
entity_id = entity_registry.async_get_entity_id(
|
||||||
|
UPDATE_DOMAIN, BLUEPRINT_DOMAIN, unique_id
|
||||||
|
)
|
||||||
|
assert entity_id is not None
|
||||||
|
|
||||||
|
state = hass.states.get(entity_id)
|
||||||
|
assert state is not None
|
||||||
|
assert state.state == "on"
|
||||||
|
assert state.attributes.get("release_url") == source_url
|
||||||
|
|
||||||
|
updated_yaml = (
|
||||||
|
"blueprint:\n"
|
||||||
|
" name: Test script blueprint\n"
|
||||||
|
" description: Updated version\n"
|
||||||
|
" domain: script\n"
|
||||||
|
f" source_url: {source_url}\n"
|
||||||
|
" input: {}\n"
|
||||||
|
"sequence:\n"
|
||||||
|
" - event: updated_event\n"
|
||||||
|
)
|
||||||
|
updated_data = yaml_util.parse_yaml(updated_yaml)
|
||||||
|
new_blueprint = models.Blueprint(
|
||||||
|
updated_data,
|
||||||
|
expected_domain=script.DOMAIN,
|
||||||
|
path=blueprint_rel_path,
|
||||||
|
schema=BLUEPRINT_SCHEMA,
|
||||||
|
)
|
||||||
|
imported_blueprint = importer.ImportedBlueprint(
|
||||||
|
blueprint_rel_path.removesuffix(".yaml"), updated_yaml, new_blueprint
|
||||||
|
)
|
||||||
|
|
||||||
|
with patch(
|
||||||
|
"homeassistant.components.blueprint.importer.fetch_blueprint_from_url",
|
||||||
|
new=AsyncMock(return_value=imported_blueprint),
|
||||||
|
) as mock_fetch:
|
||||||
|
await hass.services.async_call(
|
||||||
|
UPDATE_DOMAIN,
|
||||||
|
"install",
|
||||||
|
{"entity_id": entity_id},
|
||||||
|
blocking=True,
|
||||||
|
)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
mock_fetch.assert_awaited_once_with(hass, source_url)
|
||||||
|
mock_reload.assert_awaited_once_with(hass, blueprint_rel_path)
|
||||||
|
assert "updated_event" in blueprint_file.read_text(encoding="utf-8")
|
||||||
|
|
||||||
|
|
||||||
|
async def test_blueprint_without_source_has_no_update_entity(hass) -> None:
|
||||||
|
"""Ensure blueprints without a source URL do not expose update entities."""
|
||||||
|
blueprint_rel_path = "test_namespace/without_source.yaml"
|
||||||
|
blueprint_file = Path(
|
||||||
|
hass.config.path("blueprints", automation.DOMAIN, blueprint_rel_path)
|
||||||
|
)
|
||||||
|
blueprint_file.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
yaml_without_source = (
|
||||||
|
"blueprint:\n"
|
||||||
|
" name: No source blueprint\n"
|
||||||
|
" description: No source\n"
|
||||||
|
" domain: automation\n"
|
||||||
|
" input: {}\n"
|
||||||
|
"trigger:\n"
|
||||||
|
" - platform: event\n"
|
||||||
|
" event_type: test_event\n"
|
||||||
|
"action:\n"
|
||||||
|
" - service: test.service\n"
|
||||||
|
)
|
||||||
|
blueprint_file.write_text(yaml_without_source, encoding="utf-8")
|
||||||
|
|
||||||
|
assert await async_setup_component(
|
||||||
|
hass,
|
||||||
|
automation.DOMAIN,
|
||||||
|
{
|
||||||
|
automation.DOMAIN: {
|
||||||
|
"use_blueprint": {
|
||||||
|
"path": blueprint_rel_path,
|
||||||
|
"input": {},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
entity_registry = er.async_get(hass)
|
||||||
|
unique_id = f"{automation.DOMAIN}:{blueprint_rel_path}"
|
||||||
|
assert (
|
||||||
|
entity_registry.async_get_entity_id(UPDATE_DOMAIN, BLUEPRINT_DOMAIN, unique_id)
|
||||||
|
is None
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user