Compare commits

...

3 Commits

Author SHA1 Message Date
Cursor Agent
b2fe77b7f5 Refactor blueprint update entity and manager
This commit refactors the blueprint update entity and manager by removing unnecessary listeners and callbacks. It also renames `source_url` to `release_url` for clarity.

Co-authored-by: paulus.schoutsen <paulus.schoutsen@nabucasa.com>
2025-10-31 14:30:45 +00:00
Cursor Agent
d984e4398e Add daily refresh for blueprint updates
Co-authored-by: paulus.schoutsen <paulus.schoutsen@nabucasa.com>
2025-10-31 13:03:24 +00:00
Cursor Agent
75bd1a0310 feat: Add blueprint update entities
This commit introduces update entities for blueprints, allowing users to track and install updates for blueprints used in automations and scripts. It also adds a new event for script reloads.

Co-authored-by: paulus.schoutsen <paulus.schoutsen@nabucasa.com>
2025-10-31 11:55:03 +00:00
6 changed files with 562 additions and 4 deletions

View File

@@ -1,7 +1,9 @@
"""The blueprint integration."""
from homeassistant.const import Platform
from homeassistant.core import HomeAssistant
from homeassistant.helpers import config_validation as cv
from homeassistant.helpers.discovery import async_load_platform
from homeassistant.helpers.typing import ConfigType
from . import websocket_api
@@ -28,4 +30,7 @@ CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
"""Set up the blueprint integration."""
websocket_api.async_setup(hass)
hass.async_create_task(
async_load_platform(hass, Platform.UPDATE, DOMAIN, None, config)
)
return True

View File

@@ -204,8 +204,8 @@ class DomainBlueprints:
self.hass = hass
self.domain = domain
self.logger = logger
self._blueprint_in_use = blueprint_in_use
self._reload_blueprint_consumers = reload_blueprint_consumers
self.blueprint_in_use = blueprint_in_use
self.reload_blueprint_consumers = reload_blueprint_consumers
self._blueprints: dict[str, Blueprint | None] = {}
self._load_lock = asyncio.Lock()
self._blueprint_schema = blueprint_schema
@@ -325,7 +325,7 @@ class DomainBlueprints:
async def async_remove_blueprint(self, blueprint_path: str) -> None:
"""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)
path = self.blueprint_folder / blueprint_path
await self.hass.async_add_executor_job(path.unlink)
@@ -362,7 +362,7 @@ class DomainBlueprints:
self._blueprints[blueprint_path] = blueprint
if overrides_existing:
await self._reload_blueprint_consumers(self.hass, blueprint_path)
await self.reload_blueprint_consumers(self.hass, blueprint_path)
return overrides_existing

View 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()

View File

@@ -74,6 +74,7 @@ from .const import (
CONF_TRACE,
DOMAIN,
ENTITY_ID_FORMAT,
EVENT_SCRIPT_RELOADED,
EVENT_SCRIPT_STARTED,
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:
return
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:
"""Call a service to turn script on."""

View File

@@ -17,5 +17,6 @@ CONF_TRACE = "trace"
ENTITY_ID_FORMAT = DOMAIN + ".{}"
EVENT_SCRIPT_STARTED = "script_started"
EVENT_SCRIPT_RELOADED = "script_reloaded"
LOGGER = logging.getLogger(__package__)

View 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
)