From adfdeff84c79ba970aa3523d9d833a71907e4cf3 Mon Sep 17 00:00:00 2001 From: Mike Degatano Date: Wed, 27 Aug 2025 05:27:38 -0400 Subject: [PATCH 01/10] Use unhealthy/unsupported reason enums from aiohasupervisor (#150919) --- homeassistant/components/hassio/issues.py | 44 ++++--------------- tests/components/hassio/test_issues.py | 53 +++++++++++++++++++++++ 2 files changed, 61 insertions(+), 36 deletions(-) diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 22406e86ba118..0486dc1f85f9f 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -10,7 +10,12 @@ from uuid import UUID from aiohasupervisor import SupervisorError -from aiohasupervisor.models import ContextType, Issue as SupervisorIssue +from aiohasupervisor.models import ( + ContextType, + Issue as SupervisorIssue, + UnhealthyReason, + UnsupportedReason, +) from homeassistant.core import HassJob, HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -59,42 +64,9 @@ PLACEHOLDER_KEY_REASON = "reason" -UNSUPPORTED_REASONS = { - "apparmor", - "cgroup_version", - "connectivity_check", - "content_trust", - "dbus", - "dns_server", - "docker_configuration", - "docker_version", - "job_conditions", - "lxc", - "network_manager", - "os", - "os_agent", - "os_version", - "restart_policy", - "software", - "source_mods", - "supervisor_version", - "systemd", - "systemd_journal", - "systemd_resolved", - "virtualization_image", -} # Some unsupported reasons also mark the system as unhealthy. If the unsupported reason # provides no additional information beyond the unhealthy one then skip that repair. UNSUPPORTED_SKIP_REPAIR = {"privileged"} -UNHEALTHY_REASONS = { - "docker", - "duplicate_os_installation", - "oserror_bad_message", - "privileged", - "setup", - "supervisor", - "untrusted", -} # Keys (type + context) of issues that when found should be made into a repair ISSUE_KEYS_FOR_REPAIRS = { @@ -206,7 +178,7 @@ def unhealthy_reasons(self) -> set[str]: def unhealthy_reasons(self, reasons: set[str]) -> None: """Set unhealthy reasons. Create or delete repairs as necessary.""" for unhealthy in reasons - self.unhealthy_reasons: - if unhealthy in UNHEALTHY_REASONS: + if unhealthy in UnhealthyReason: translation_key = f"{ISSUE_KEY_UNHEALTHY}_{unhealthy}" translation_placeholders = None else: @@ -238,7 +210,7 @@ def unsupported_reasons(self) -> set[str]: def unsupported_reasons(self, reasons: set[str]) -> None: """Set unsupported reasons. Create or delete repairs as necessary.""" for unsupported in reasons - UNSUPPORTED_SKIP_REPAIR - self.unsupported_reasons: - if unsupported in UNSUPPORTED_REASONS: + if unsupported in UnsupportedReason: translation_key = f"{ISSUE_KEY_UNSUPPORTED}_{unsupported}" translation_placeholders = None else: diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index a4ad0a4a004b7..ddcbe5708c66f 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -163,6 +163,31 @@ async def test_unhealthy_issues( assert_repair_in_list(msg["result"]["issues"], unhealthy=True, reason="setup") +@pytest.mark.usefixtures("all_setup_requests") +@pytest.mark.parametrize("unhealthy_reason", list(UnhealthyReason)) +async def test_unhealthy_reasons( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_ws_client: WebSocketGenerator, + unhealthy_reason: UnhealthyReason, +) -> None: + """Test all unhealthy reasons in client library are properly made into repairs with a translation.""" + mock_resolution_info(supervisor_client, unhealthy=[unhealthy_reason]) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_repair_in_list( + msg["result"]["issues"], unhealthy=True, reason=unhealthy_reason.value + ) + + @pytest.mark.usefixtures("all_setup_requests") async def test_unsupported_issues( hass: HomeAssistant, @@ -190,6 +215,34 @@ async def test_unsupported_issues( assert_repair_in_list(msg["result"]["issues"], unhealthy=False, reason="os") +@pytest.mark.usefixtures("all_setup_requests") +@pytest.mark.parametrize( + "unsupported_reason", + [r for r in UnsupportedReason if r != UnsupportedReason.PRIVILEGED], +) +async def test_unsupported_reasons( + hass: HomeAssistant, + supervisor_client: AsyncMock, + hass_ws_client: WebSocketGenerator, + unsupported_reason: UnsupportedReason, +) -> None: + """Test all unsupported reasons in client library are properly made into repairs with a translation.""" + mock_resolution_info(supervisor_client, unsupported=[unsupported_reason]) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json({"id": 1, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_repair_in_list( + msg["result"]["issues"], unhealthy=False, reason=unsupported_reason.value + ) + + @pytest.mark.usefixtures("all_setup_requests") async def test_unhealthy_issues_add_remove( hass: HomeAssistant, From 20e4d37cc60828963b95d9f592598e8f057a8dc7 Mon Sep 17 00:00:00 2001 From: Denis Shulyaka Date: Wed, 27 Aug 2025 12:41:14 +0300 Subject: [PATCH 02/10] Add ai_task.generate_image action (#151101) --- homeassistant/components/ai_task/__init__.py | 70 ++++- homeassistant/components/ai_task/const.py | 9 + homeassistant/components/ai_task/entity.py | 24 +- homeassistant/components/ai_task/icons.json | 3 + .../components/ai_task/manifest.json | 2 +- .../components/ai_task/media_source.py | 81 +++++ .../components/ai_task/services.yaml | 27 ++ homeassistant/components/ai_task/strings.json | 22 ++ homeassistant/components/ai_task/task.py | 286 +++++++++++++++--- tests/components/ai_task/conftest.py | 25 +- tests/components/ai_task/test_media_source.py | 64 ++++ tests/components/ai_task/test_task.py | 104 ++++++- 12 files changed, 661 insertions(+), 56 deletions(-) create mode 100644 homeassistant/components/ai_task/media_source.py create mode 100644 tests/components/ai_task/test_media_source.py diff --git a/homeassistant/components/ai_task/__init__.py b/homeassistant/components/ai_task/__init__.py index a16e11c05d753..adae039ea5c2d 100644 --- a/homeassistant/components/ai_task/__init__.py +++ b/homeassistant/components/ai_task/__init__.py @@ -3,8 +3,10 @@ import logging from typing import Any +from aiohttp import web import voluptuous as vol +from homeassistant.components.http import KEY_HASS, HomeAssistantView from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_ENTITY_ID, CONF_DESCRIPTION, CONF_SELECTOR from homeassistant.core import ( @@ -26,14 +28,24 @@ ATTR_STRUCTURE, ATTR_TASK_NAME, DATA_COMPONENT, + DATA_IMAGES, DATA_PREFERENCES, DOMAIN, SERVICE_GENERATE_DATA, + SERVICE_GENERATE_IMAGE, AITaskEntityFeature, ) from .entity import AITaskEntity from .http import async_setup as async_setup_http -from .task import GenDataTask, GenDataTaskResult, async_generate_data +from .task import ( + GenDataTask, + GenDataTaskResult, + GenImageTask, + GenImageTaskResult, + ImageData, + async_generate_data, + async_generate_image, +) __all__ = [ "DOMAIN", @@ -41,7 +53,11 @@ "AITaskEntityFeature", "GenDataTask", "GenDataTaskResult", + "GenImageTask", + "GenImageTaskResult", + "ImageData", "async_generate_data", + "async_generate_image", "async_setup", "async_setup_entry", "async_unload_entry", @@ -78,8 +94,10 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: entity_component = EntityComponent[AITaskEntity](_LOGGER, DOMAIN, hass) hass.data[DATA_COMPONENT] = entity_component hass.data[DATA_PREFERENCES] = AITaskPreferences(hass) + hass.data[DATA_IMAGES] = {} await hass.data[DATA_PREFERENCES].async_load() async_setup_http(hass) + hass.http.register_view(ImageView) hass.services.async_register( DOMAIN, SERVICE_GENERATE_DATA, @@ -101,6 +119,23 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: supports_response=SupportsResponse.ONLY, job_type=HassJobType.Coroutinefunction, ) + hass.services.async_register( + DOMAIN, + SERVICE_GENERATE_IMAGE, + async_service_generate_image, + schema=vol.Schema( + { + vol.Required(ATTR_TASK_NAME): cv.string, + vol.Required(ATTR_ENTITY_ID): cv.entity_id, + vol.Required(ATTR_INSTRUCTIONS): cv.string, + vol.Optional(ATTR_ATTACHMENTS): vol.All( + cv.ensure_list, [selector.MediaSelector({"accept": ["*/*"]})] + ), + } + ), + supports_response=SupportsResponse.ONLY, + job_type=HassJobType.Coroutinefunction, + ) return True @@ -115,11 +150,16 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: async def async_service_generate_data(call: ServiceCall) -> ServiceResponse: - """Run the run task service.""" + """Run the data task service.""" result = await async_generate_data(hass=call.hass, **call.data) return result.as_dict() +async def async_service_generate_image(call: ServiceCall) -> ServiceResponse: + """Run the image task service.""" + return await async_generate_image(hass=call.hass, **call.data) + + class AITaskPreferences: """AI Task preferences.""" @@ -164,3 +204,29 @@ def async_set_preferences( def as_dict(self) -> dict[str, str | None]: """Get the current preferences.""" return {key: getattr(self, key) for key in self.KEYS} + + +class ImageView(HomeAssistantView): + """View to generated images.""" + + url = f"/api/{DOMAIN}/images/{{filename}}" + name = f"api:{DOMAIN}/images" + requires_auth = False + + async def get( + self, + request: web.Request, + filename: str, + ) -> web.Response: + """Serve image.""" + hass = request.app[KEY_HASS] + image_storage = hass.data[DATA_IMAGES] + image_data = image_storage.get(filename) + + if image_data is None: + raise web.HTTPNotFound + + return web.Response( + body=image_data.data, + content_type=image_data.mime_type, + ) diff --git a/homeassistant/components/ai_task/const.py b/homeassistant/components/ai_task/const.py index 09948e9b67373..b62f8002ecf25 100644 --- a/homeassistant/components/ai_task/const.py +++ b/homeassistant/components/ai_task/const.py @@ -12,12 +12,18 @@ from . import AITaskPreferences from .entity import AITaskEntity + from .task import ImageData DOMAIN = "ai_task" DATA_COMPONENT: HassKey[EntityComponent[AITaskEntity]] = HassKey(DOMAIN) DATA_PREFERENCES: HassKey[AITaskPreferences] = HassKey(f"{DOMAIN}_preferences") +DATA_IMAGES: HassKey[dict[str, ImageData]] = HassKey(f"{DOMAIN}_images") + +IMAGE_EXPIRY_TIME = 60 * 60 # 1 hour +MAX_IMAGES = 20 SERVICE_GENERATE_DATA = "generate_data" +SERVICE_GENERATE_IMAGE = "generate_image" ATTR_INSTRUCTIONS: Final = "instructions" ATTR_TASK_NAME: Final = "task_name" @@ -38,3 +44,6 @@ class AITaskEntityFeature(IntFlag): SUPPORT_ATTACHMENTS = 2 """Support attachments with generate data.""" + + GENERATE_IMAGE = 4 + """Generate images based on instructions.""" diff --git a/homeassistant/components/ai_task/entity.py b/homeassistant/components/ai_task/entity.py index 4c5cd186943d4..5b11fe95f2824 100644 --- a/homeassistant/components/ai_task/entity.py +++ b/homeassistant/components/ai_task/entity.py @@ -18,7 +18,7 @@ from homeassistant.util import dt as dt_util from .const import DEFAULT_SYSTEM_PROMPT, DOMAIN, AITaskEntityFeature -from .task import GenDataTask, GenDataTaskResult +from .task import GenDataTask, GenDataTaskResult, GenImageTask, GenImageTaskResult class AITaskEntity(RestoreEntity): @@ -57,7 +57,7 @@ async def async_internal_added_to_hass(self) -> None: async def _async_get_ai_task_chat_log( self, session: ChatSession, - task: GenDataTask, + task: GenDataTask | GenImageTask, ) -> AsyncGenerator[ChatLog]: """Context manager used to manage the ChatLog used during an AI Task.""" # pylint: disable-next=contextmanager-generator-missing-cleanup @@ -104,3 +104,23 @@ async def _async_generate_data( ) -> GenDataTaskResult: """Handle a gen data task.""" raise NotImplementedError + + @final + async def internal_async_generate_image( + self, + session: ChatSession, + task: GenImageTask, + ) -> GenImageTaskResult: + """Run a gen image task.""" + self.__last_activity = dt_util.utcnow().isoformat() + self.async_write_ha_state() + async with self._async_get_ai_task_chat_log(session, task) as chat_log: + return await self._async_generate_image(task, chat_log) + + async def _async_generate_image( + self, + task: GenImageTask, + chat_log: ChatLog, + ) -> GenImageTaskResult: + """Handle a gen image task.""" + raise NotImplementedError diff --git a/homeassistant/components/ai_task/icons.json b/homeassistant/components/ai_task/icons.json index 24233372312d3..2765402abf804 100644 --- a/homeassistant/components/ai_task/icons.json +++ b/homeassistant/components/ai_task/icons.json @@ -7,6 +7,9 @@ "services": { "generate_data": { "service": "mdi:file-star-four-points-outline" + }, + "generate_image": { + "service": "mdi:star-four-points-box-outline" } } } diff --git a/homeassistant/components/ai_task/manifest.json b/homeassistant/components/ai_task/manifest.json index d05faf1805535..9e2eec4651d64 100644 --- a/homeassistant/components/ai_task/manifest.json +++ b/homeassistant/components/ai_task/manifest.json @@ -1,7 +1,7 @@ { "domain": "ai_task", "name": "AI Task", - "after_dependencies": ["camera"], + "after_dependencies": ["camera", "http"], "codeowners": ["@home-assistant/core"], "dependencies": ["conversation", "media_source"], "documentation": "https://www.home-assistant.io/integrations/ai_task", diff --git a/homeassistant/components/ai_task/media_source.py b/homeassistant/components/ai_task/media_source.py new file mode 100644 index 0000000000000..08d3a29e95fca --- /dev/null +++ b/homeassistant/components/ai_task/media_source.py @@ -0,0 +1,81 @@ +"""Expose images as media sources.""" + +from __future__ import annotations + +import logging + +from homeassistant.components.media_player import BrowseError, MediaClass +from homeassistant.components.media_source import ( + BrowseMediaSource, + MediaSource, + MediaSourceItem, + PlayMedia, + Unresolvable, +) +from homeassistant.core import HomeAssistant + +from .const import DATA_IMAGES, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +async def async_get_media_source(hass: HomeAssistant) -> ImageMediaSource: + """Set up image media source.""" + _LOGGER.debug("Setting up image media source") + return ImageMediaSource(hass) + + +class ImageMediaSource(MediaSource): + """Provide images as media sources.""" + + name: str = "AI Generated Images" + + def __init__(self, hass: HomeAssistant) -> None: + """Initialize ImageMediaSource.""" + super().__init__(DOMAIN) + self.hass = hass + + async def async_resolve_media(self, item: MediaSourceItem) -> PlayMedia: + """Resolve media to a url.""" + image_storage = self.hass.data[DATA_IMAGES] + image = image_storage.get(item.identifier) + + if image is None: + raise Unresolvable(f"Could not resolve media item: {item.identifier}") + + return PlayMedia(f"/api/{DOMAIN}/images/{item.identifier}", image.mime_type) + + async def async_browse_media( + self, + item: MediaSourceItem, + ) -> BrowseMediaSource: + """Return media.""" + if item.identifier: + raise BrowseError("Unknown item") + + image_storage = self.hass.data[DATA_IMAGES] + + children = [ + BrowseMediaSource( + domain=DOMAIN, + identifier=filename, + media_class=MediaClass.IMAGE, + media_content_type=image.mime_type, + title=image.title or filename, + can_play=True, + can_expand=False, + ) + for filename, image in image_storage.items() + ] + + return BrowseMediaSource( + domain=DOMAIN, + identifier=None, + media_class=MediaClass.APP, + media_content_type="", + title="AI Generated Images", + can_play=False, + can_expand=True, + children_media_class=MediaClass.IMAGE, + children=children, + ) diff --git a/homeassistant/components/ai_task/services.yaml b/homeassistant/components/ai_task/services.yaml index feefa70a30bb2..17a3b499bfe28 100644 --- a/homeassistant/components/ai_task/services.yaml +++ b/homeassistant/components/ai_task/services.yaml @@ -31,3 +31,30 @@ generate_data: media: accept: - "*" +generate_image: + fields: + task_name: + example: "picture of a dog" + required: true + selector: + text: + instructions: + example: "Generate a high quality square image of a dog on transparent background" + required: true + selector: + text: + multiline: true + entity_id: + required: true + selector: + entity: + filter: + domain: ai_task + supported_features: + - ai_task.AITaskEntityFeature.GENERATE_IMAGE + attachments: + required: false + selector: + media: + accept: + - "*" diff --git a/homeassistant/components/ai_task/strings.json b/homeassistant/components/ai_task/strings.json index 261381b7c311b..3ec366afb0dbe 100644 --- a/homeassistant/components/ai_task/strings.json +++ b/homeassistant/components/ai_task/strings.json @@ -25,6 +25,28 @@ "description": "List of files to attach for multi-modal AI analysis." } } + }, + "generate_image": { + "name": "Generate image", + "description": "Uses AI to generate image.", + "fields": { + "task_name": { + "name": "Task name", + "description": "Name of the task." + }, + "instructions": { + "name": "Instructions", + "description": "Instructions that explains the image to be generated." + }, + "entity_id": { + "name": "Entity ID", + "description": "Entity ID to run the task on." + }, + "attachments": { + "name": "Attachments", + "description": "List of files to attach for using as references." + } + } } } } diff --git a/homeassistant/components/ai_task/task.py b/homeassistant/components/ai_task/task.py index 3cc43f8c07a40..4efe38425a898 100644 --- a/homeassistant/components/ai_task/task.py +++ b/homeassistant/components/ai_task/task.py @@ -3,6 +3,8 @@ from __future__ import annotations from dataclasses import dataclass +from datetime import datetime +from functools import partial import mimetypes from pathlib import Path import tempfile @@ -11,11 +13,22 @@ import voluptuous as vol from homeassistant.components import camera, conversation, media_source -from homeassistant.core import HomeAssistant, callback +from homeassistant.core import HomeAssistant, ServiceResponse, callback from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.chat_session import async_get_chat_session - -from .const import DATA_COMPONENT, DATA_PREFERENCES, AITaskEntityFeature +from homeassistant.helpers.chat_session import ChatSession, async_get_chat_session +from homeassistant.helpers.event import async_call_later +from homeassistant.helpers.network import get_url +from homeassistant.util import RE_SANITIZE_FILENAME, slugify + +from .const import ( + DATA_COMPONENT, + DATA_IMAGES, + DATA_PREFERENCES, + DOMAIN, + IMAGE_EXPIRY_TIME, + MAX_IMAGES, + AITaskEntityFeature, +) def _save_camera_snapshot(image: camera.Image) -> Path: @@ -29,43 +42,15 @@ def _save_camera_snapshot(image: camera.Image) -> Path: return Path(temp_file.name) -async def async_generate_data( +async def _resolve_attachments( hass: HomeAssistant, - *, - task_name: str, - entity_id: str | None = None, - instructions: str, - structure: vol.Schema | None = None, + session: ChatSession, attachments: list[dict] | None = None, -) -> GenDataTaskResult: - """Run a task in the AI Task integration.""" - if entity_id is None: - entity_id = hass.data[DATA_PREFERENCES].gen_data_entity_id - - if entity_id is None: - raise HomeAssistantError("No entity_id provided and no preferred entity set") - - entity = hass.data[DATA_COMPONENT].get_entity(entity_id) - if entity is None: - raise HomeAssistantError(f"AI Task entity {entity_id} not found") - - if AITaskEntityFeature.GENERATE_DATA not in entity.supported_features: - raise HomeAssistantError( - f"AI Task entity {entity_id} does not support generating data" - ) - - # Resolve attachments +) -> list[conversation.Attachment]: + """Resolve attachments for a task.""" resolved_attachments: list[conversation.Attachment] = [] created_files: list[Path] = [] - if ( - attachments - and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features - ): - raise HomeAssistantError( - f"AI Task entity {entity_id} does not support attachments" - ) - for attachment in attachments or []: media_content_id = attachment["media_content_id"] @@ -104,20 +89,59 @@ async def async_generate_data( ) ) - with async_get_chat_session(hass) as session: - if created_files: + if not created_files: + return resolved_attachments + + def cleanup_files() -> None: + """Cleanup temporary files.""" + for file in created_files: + file.unlink(missing_ok=True) - def cleanup_files() -> None: - """Cleanup temporary files.""" - for file in created_files: - file.unlink(missing_ok=True) + @callback + def cleanup_files_callback() -> None: + """Cleanup temporary files.""" + hass.async_add_executor_job(cleanup_files) - @callback - def cleanup_files_callback() -> None: - """Cleanup temporary files.""" - hass.async_add_executor_job(cleanup_files) + session.async_on_cleanup(cleanup_files_callback) - session.async_on_cleanup(cleanup_files_callback) + return resolved_attachments + + +async def async_generate_data( + hass: HomeAssistant, + *, + task_name: str, + entity_id: str | None = None, + instructions: str, + structure: vol.Schema | None = None, + attachments: list[dict] | None = None, +) -> GenDataTaskResult: + """Run a data generation task in the AI Task integration.""" + if entity_id is None: + entity_id = hass.data[DATA_PREFERENCES].gen_data_entity_id + + if entity_id is None: + raise HomeAssistantError("No entity_id provided and no preferred entity set") + + entity = hass.data[DATA_COMPONENT].get_entity(entity_id) + if entity is None: + raise HomeAssistantError(f"AI Task entity {entity_id} not found") + + if AITaskEntityFeature.GENERATE_DATA not in entity.supported_features: + raise HomeAssistantError( + f"AI Task entity {entity_id} does not support generating data" + ) + + if ( + attachments + and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features + ): + raise HomeAssistantError( + f"AI Task entity {entity_id} does not support attachments" + ) + + with async_get_chat_session(hass) as session: + resolved_attachments = await _resolve_attachments(hass, session, attachments) return await entity.internal_async_generate_data( session, @@ -130,6 +154,97 @@ def cleanup_files_callback() -> None: ) +def _cleanup_images(image_storage: dict[str, ImageData], num_to_remove: int) -> None: + """Remove old images to keep the storage size under the limit.""" + if num_to_remove <= 0: + return + + if num_to_remove >= len(image_storage): + image_storage.clear() + return + + sorted_images = sorted( + image_storage.items(), + key=lambda item: item[1].timestamp, + ) + + for filename, _ in sorted_images[:num_to_remove]: + image_storage.pop(filename, None) + + +async def async_generate_image( + hass: HomeAssistant, + *, + task_name: str, + entity_id: str, + instructions: str, + attachments: list[dict] | None = None, +) -> ServiceResponse: + """Run an image generation task in the AI Task integration.""" + entity = hass.data[DATA_COMPONENT].get_entity(entity_id) + if entity is None: + raise HomeAssistantError(f"AI Task entity {entity_id} not found") + + if AITaskEntityFeature.GENERATE_IMAGE not in entity.supported_features: + raise HomeAssistantError( + f"AI Task entity {entity_id} does not support generating images" + ) + + if ( + attachments + and AITaskEntityFeature.SUPPORT_ATTACHMENTS not in entity.supported_features + ): + raise HomeAssistantError( + f"AI Task entity {entity_id} does not support attachments" + ) + + with async_get_chat_session(hass) as session: + resolved_attachments = await _resolve_attachments(hass, session, attachments) + + task_result = await entity.internal_async_generate_image( + session, + GenImageTask( + name=task_name, + instructions=instructions, + attachments=resolved_attachments or None, + ), + ) + + service_result = task_result.as_dict() + image_data = service_result.pop("image_data") + if service_result.get("revised_prompt") is None: + service_result["revised_prompt"] = instructions + + image_storage = hass.data[DATA_IMAGES] + + if len(image_storage) + 1 > MAX_IMAGES: + _cleanup_images(image_storage, len(image_storage) + 1 - MAX_IMAGES) + + current_time = datetime.now() + ext = mimetypes.guess_extension(task_result.mime_type, False) or ".png" + sanitized_task_name = RE_SANITIZE_FILENAME.sub("", slugify(task_name)) + filename = f"{current_time.strftime('%Y-%m-%d_%H%M%S')}_{sanitized_task_name}{ext}" + + image_storage[filename] = ImageData( + data=image_data, + timestamp=int(current_time.timestamp()), + mime_type=task_result.mime_type, + title=service_result["revised_prompt"], + ) + + def _purge_image(filename: str, now: datetime) -> None: + """Remove image from storage.""" + image_storage.pop(filename, None) + + if IMAGE_EXPIRY_TIME > 0: + async_call_later(hass, IMAGE_EXPIRY_TIME, partial(_purge_image, filename)) + + service_result["url"] = get_url(hass) + f"/api/{DOMAIN}/images/{filename}" + service_result["media_source_id"] = f"media-source://{DOMAIN}/images/{filename}" + + return service_result + + @dataclass(slots=True) class GenDataTask: """Gen data task to be processed.""" @@ -167,3 +282,80 @@ def as_dict(self) -> dict[str, Any]: "conversation_id": self.conversation_id, "data": self.data, } + + +@dataclass(slots=True) +class GenImageTask: + """Gen image task to be processed.""" + + name: str + """Name of the task.""" + + instructions: str + """Instructions on what needs to be done.""" + + attachments: list[conversation.Attachment] | None = None + """List of attachments to go along the instructions.""" + + def __str__(self) -> str: + """Return task as a string.""" + return f"" + + +@dataclass(slots=True) +class GenImageTaskResult: + """Result of gen image task.""" + + image_data: bytes + """Raw image data generated by the model.""" + + conversation_id: str + """Unique identifier for the conversation.""" + + mime_type: str + """MIME type of the generated image.""" + + width: int | None = None + """Width of the generated image, if available.""" + + height: int | None = None + """Height of the generated image, if available.""" + + model: str | None = None + """Model used to generate the image, if available.""" + + revised_prompt: str | None = None + """Revised prompt used to generate the image, if applicable.""" + + def as_dict(self) -> dict[str, Any]: + """Return result as a dict.""" + return { + "image_data": self.image_data, + "conversation_id": self.conversation_id, + "mime_type": self.mime_type, + "width": self.width, + "height": self.height, + "model": self.model, + "revised_prompt": self.revised_prompt, + } + + +@dataclass(slots=True) +class ImageData: + """Image data for stored generated images.""" + + data: bytes + """Raw image data.""" + + timestamp: int + """Timestamp when the image was generated, as a Unix timestamp.""" + + mime_type: str + """MIME type of the image.""" + + title: str + """Title of the image, usually the prompt used to generate it.""" + + def __str__(self) -> str: + """Return image data as a string.""" + return f"" diff --git a/tests/components/ai_task/conftest.py b/tests/components/ai_task/conftest.py index 05d34b15ddc0d..06f9a56a813f4 100644 --- a/tests/components/ai_task/conftest.py +++ b/tests/components/ai_task/conftest.py @@ -10,6 +10,8 @@ AITaskEntityFeature, GenDataTask, GenDataTaskResult, + GenImageTask, + GenImageTaskResult, ) from homeassistant.components.conversation import AssistantContent, ChatLog from homeassistant.config_entries import ConfigEntry, ConfigFlow @@ -36,13 +38,16 @@ class MockAITaskEntity(AITaskEntity): _attr_name = "Test Task Entity" _attr_supported_features = ( - AITaskEntityFeature.GENERATE_DATA | AITaskEntityFeature.SUPPORT_ATTACHMENTS + AITaskEntityFeature.GENERATE_DATA + | AITaskEntityFeature.SUPPORT_ATTACHMENTS + | AITaskEntityFeature.GENERATE_IMAGE ) def __init__(self) -> None: """Initialize the mock entity.""" super().__init__() self.mock_generate_data_tasks = [] + self.mock_generate_image_tasks = [] async def _async_generate_data( self, task: GenDataTask, chat_log: ChatLog @@ -63,6 +68,24 @@ async def _async_generate_data( data=data, ) + async def _async_generate_image( + self, task: GenImageTask, chat_log: ChatLog + ) -> GenImageTaskResult: + """Mock handling of generate image task.""" + self.mock_generate_image_tasks.append(task) + chat_log.async_add_assistant_content_without_tools( + AssistantContent(self.entity_id, "") + ) + return GenImageTaskResult( + conversation_id=chat_log.conversation_id, + image_data=b"mock_image_data", + mime_type="image/png", + width=1536, + height=1024, + model="mock_model", + revised_prompt="mock_revised_prompt", + ) + @pytest.fixture def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: diff --git a/tests/components/ai_task/test_media_source.py b/tests/components/ai_task/test_media_source.py new file mode 100644 index 0000000000000..718d729920769 --- /dev/null +++ b/tests/components/ai_task/test_media_source.py @@ -0,0 +1,64 @@ +"""Test ai_task media source.""" + +import pytest + +from homeassistant.components import media_source +from homeassistant.components.ai_task import ImageData +from homeassistant.core import HomeAssistant + + +@pytest.fixture(name="image_id") +async def mock_image_generate(hass: HomeAssistant) -> str: + """Mock image generation and return the image_id.""" + image_storage = hass.data.setdefault("ai_task_images", {}) + filename = "2025-06-15_150640_test_task.png" + image_storage[filename] = ImageData( + data=b"A", + timestamp=1750000000, + mime_type="image/png", + title="Mock Image", + ) + return filename + + +async def test_browsing( + hass: HomeAssistant, init_components: None, image_id: str +) -> None: + """Test browsing image media source.""" + item = await media_source.async_browse_media(hass, "media-source://ai_task") + + assert item is not None + assert item.title == "AI Generated Images" + assert len(item.children) == 1 + assert item.children[0].media_content_type == "image/png" + assert item.children[0].identifier == image_id + assert item.children[0].title == "Mock Image" + + with pytest.raises( + media_source.BrowseError, + match="Unknown item", + ): + await media_source.async_browse_media( + hass, "media-source://ai_task/invalid_path" + ) + + +async def test_resolving( + hass: HomeAssistant, init_components: None, image_id: str +) -> None: + """Test resolving.""" + item = await media_source.async_resolve_media( + hass, f"media-source://ai_task/{image_id}", None + ) + assert item is not None + assert item.url == f"/api/ai_task/images/{image_id}" + assert item.mime_type == "image/png" + + invalid_id = "aabbccddeeff" + with pytest.raises( + media_source.Unresolvable, + match=f"Could not resolve media item: {invalid_id}", + ): + await media_source.async_resolve_media( + hass, f"media-source://ai_task/{invalid_id}", None + ) diff --git a/tests/components/ai_task/test_task.py b/tests/components/ai_task/test_task.py index 7eb75b62bb025..2bebf7b60bb0f 100644 --- a/tests/components/ai_task/test_task.py +++ b/tests/components/ai_task/test_task.py @@ -1,6 +1,6 @@ """Test tasks for the AI Task integration.""" -from datetime import timedelta +from datetime import datetime, timedelta from pathlib import Path from unittest.mock import patch @@ -9,7 +9,12 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components import media_source -from homeassistant.components.ai_task import AITaskEntityFeature, async_generate_data +from homeassistant.components.ai_task import ( + AITaskEntityFeature, + ImageData, + async_generate_data, + async_generate_image, +) from homeassistant.components.camera import Image from homeassistant.components.conversation import async_get_chat_log from homeassistant.const import STATE_UNKNOWN @@ -232,7 +237,9 @@ async def test_generate_data_mixed_attachments( hass, dt_util.utcnow() + chat_session.CONVERSATION_TIMEOUT + timedelta(seconds=1), ) - await hass.async_block_till_done() + await hass.async_block_till_done() # Need several iterations + await hass.async_block_till_done() # because one iteration of the loop + await hass.async_block_till_done() # simply schedules the cleanup # Verify the temporary file cleaned up assert not camera_attachment.path.exists() @@ -242,3 +249,94 @@ async def test_generate_data_mixed_attachments( assert media_attachment.media_content_id == "media-source://media_player/video.mp4" assert media_attachment.mime_type == "video/mp4" assert media_attachment.path == Path("/media/test.mp4") + + +async def test_generate_image( + hass: HomeAssistant, + init_components: None, + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test generating image service.""" + with pytest.raises( + HomeAssistantError, match="AI Task entity ai_task.unknown not found" + ): + await async_generate_image( + hass, + task_name="Test Task", + entity_id="ai_task.unknown", + instructions="Test prompt", + ) + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state == STATE_UNKNOWN + + result = await async_generate_image( + hass, + task_name="Test Task", + entity_id=TEST_ENTITY_ID, + instructions="Test prompt", + ) + assert "image_data" not in result + assert result["media_source_id"].startswith("media-source://ai_task/images/") + assert result["media_source_id"].endswith("_test_task.png") + assert result["url"].startswith("http://10.10.10.10:8123/api/ai_task/images/") + assert result["url"].endswith("_test_task.png") + assert result["mime_type"] == "image/png" + assert result["model"] == "mock_model" + assert result["revised_prompt"] == "mock_revised_prompt" + assert result["height"] == 1024 + assert result["width"] == 1536 + + state = hass.states.get(TEST_ENTITY_ID) + assert state is not None + assert state.state != STATE_UNKNOWN + + mock_ai_task_entity.supported_features = AITaskEntityFeature(0) + with pytest.raises( + HomeAssistantError, + match="AI Task entity ai_task.test_task_entity does not support generating images", + ): + await async_generate_image( + hass, + task_name="Test Task", + entity_id=TEST_ENTITY_ID, + instructions="Test prompt", + ) + + +async def test_image_cleanup( + hass: HomeAssistant, + init_components: None, + mock_ai_task_entity: MockAITaskEntity, +) -> None: + """Test image cache cleanup.""" + image_storage = hass.data.setdefault("ai_task_images", {}) + image_storage.clear() + image_storage.update( + { + str(idx): ImageData( + data=b"mock_image_data", + timestamp=int(datetime.now().timestamp()), + mime_type="image/png", + title="Test Image", + ) + for idx in range(20) + } + ) + assert len(image_storage) == 20 + + result = await async_generate_image( + hass, + task_name="Test Task", + entity_id=TEST_ENTITY_ID, + instructions="Test prompt", + ) + + assert result["url"].split("/")[-1] in image_storage + assert len(image_storage) == 20 + + async_fire_time_changed(hass, dt_util.utcnow() + timedelta(hours=1, seconds=1)) + await hass.async_block_till_done() + + assert len(image_storage) == 19 From 81a5b4a68435ebcd72c2f0629de43162f2d83de2 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 27 Aug 2025 12:01:34 +0200 Subject: [PATCH 03/10] Refactor zwave_js discovery schema foundation (#151146) --- homeassistant/components/zwave_js/__init__.py | 22 +- .../components/zwave_js/binary_sensor.py | 156 +++++++++--- homeassistant/components/zwave_js/cover.py | 18 +- .../components/zwave_js/discovery.py | 199 ++++++--------- .../zwave_js/discovery_data_template.py | 47 +--- homeassistant/components/zwave_js/entity.py | 41 +++- homeassistant/components/zwave_js/helpers.py | 10 - homeassistant/components/zwave_js/migrate.py | 7 +- homeassistant/components/zwave_js/models.py | 228 +++++++++++++++++- 9 files changed, 489 insertions(+), 239 deletions(-) diff --git a/homeassistant/components/zwave_js/__init__.py b/homeassistant/components/zwave_js/__init__.py index af42f024e6ac0..f78c201340aae 100644 --- a/homeassistant/components/zwave_js/__init__.py +++ b/homeassistant/components/zwave_js/__init__.py @@ -115,11 +115,7 @@ ZWAVE_JS_VALUE_NOTIFICATION_EVENT, ZWAVE_JS_VALUE_UPDATED_EVENT, ) -from .discovery import ( - ZwaveDiscoveryInfo, - async_discover_node_values, - async_discover_single_value, -) +from .discovery import async_discover_node_values, async_discover_single_value from .helpers import ( async_disable_server_logging_if_needed, async_enable_server_logging_if_needed, @@ -131,7 +127,7 @@ get_valueless_base_unique_id, ) from .migrate import async_migrate_discovered_value -from .models import ZwaveJSConfigEntry, ZwaveJSData +from .models import PlatformZwaveDiscoveryInfo, ZwaveJSConfigEntry, ZwaveJSData from .services import async_setup_services CONNECT_TIMEOUT = 10 @@ -776,7 +772,7 @@ async def async_on_node_ready(self, node: ZwaveNode) -> None: # Remove any old value ids if this is a reinterview. self.controller_events.discovered_value_ids.pop(device.id, None) - value_updates_disc_info: dict[str, ZwaveDiscoveryInfo] = {} + value_updates_disc_info: dict[str, PlatformZwaveDiscoveryInfo] = {} # run discovery on all node values and create/update entities await asyncio.gather( @@ -858,8 +854,8 @@ async def async_on_node_ready(self, node: ZwaveNode) -> None: async def async_handle_discovery_info( self, device: dr.DeviceEntry, - disc_info: ZwaveDiscoveryInfo, - value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], + disc_info: PlatformZwaveDiscoveryInfo, + value_updates_disc_info: dict[str, PlatformZwaveDiscoveryInfo], ) -> None: """Handle discovery info and all dependent tasks.""" platform = disc_info.platform @@ -901,7 +897,9 @@ async def async_handle_discovery_info( ) async def async_on_value_added( - self, value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value + self, + value_updates_disc_info: dict[str, PlatformZwaveDiscoveryInfo], + value: Value, ) -> None: """Fire value updated event.""" # If node isn't ready or a device for this node doesn't already exist, we can @@ -1036,7 +1034,9 @@ def async_on_notification(self, event: dict[str, Any]) -> None: @callback def async_on_value_updated_fire_event( - self, value_updates_disc_info: dict[str, ZwaveDiscoveryInfo], value: Value + self, + value_updates_disc_info: dict[str, PlatformZwaveDiscoveryInfo], + value: Value, ) -> None: """Fire value updated event.""" # Get the discovery info for the value that was updated. If there is diff --git a/homeassistant/components/zwave_js/binary_sensor.py b/homeassistant/components/zwave_js/binary_sensor.py index 5b7fe4f4d7c24..2280ba69c0113 100644 --- a/homeassistant/components/zwave_js/binary_sensor.py +++ b/homeassistant/components/zwave_js/binary_sensor.py @@ -2,7 +2,7 @@ from __future__ import annotations -from dataclasses import dataclass +from dataclasses import dataclass, field from zwave_js_server.const import CommandClass from zwave_js_server.const.command_class.lock import DOOR_STATUS_PROPERTY @@ -17,15 +17,21 @@ BinarySensorEntity, BinarySensorEntityDescription, ) -from homeassistant.const import EntityCategory +from homeassistant.const import EntityCategory, Platform from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.dispatcher import async_dispatcher_connect +from homeassistant.helpers.entity import Entity from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .const import DOMAIN -from .discovery import ZwaveDiscoveryInfo -from .entity import ZWaveBaseEntity -from .models import ZwaveJSConfigEntry +from .entity import NewZwaveDiscoveryInfo, ZWaveBaseEntity +from .models import ( + NewZWaveDiscoverySchema, + ValueType, + ZwaveDiscoveryInfo, + ZwaveJSConfigEntry, + ZWaveValueDiscoverySchema, +) PARALLEL_UPDATES = 0 @@ -50,11 +56,11 @@ NOTIFICATION_GAS = "18" -@dataclass(frozen=True) +@dataclass(frozen=True, kw_only=True) class NotificationZWaveJSEntityDescription(BinarySensorEntityDescription): """Represent a Z-Wave JS binary sensor entity description.""" - off_state: str = "0" + not_states: set[str] = field(default_factory=lambda: {"0"}) states: tuple[str, ...] | None = None @@ -65,6 +71,13 @@ class PropertyZWaveJSEntityDescription(BinarySensorEntityDescription): on_states: tuple[str, ...] +@dataclass(frozen=True, kw_only=True) +class NewNotificationZWaveJSEntityDescription(BinarySensorEntityDescription): + """Represent a Z-Wave JS binary sensor entity description.""" + + state_key: str + + # Mappings for Notification sensors # https://github.com/zwave-js/specs/blob/master/Registries/Notification%20Command%20Class%2C%20list%20of%20assigned%20Notifications.xlsx # @@ -106,24 +119,6 @@ class PropertyZWaveJSEntityDescription(BinarySensorEntityDescription): # - Sump pump failure NOTIFICATION_SENSOR_MAPPINGS: tuple[NotificationZWaveJSEntityDescription, ...] = ( - NotificationZWaveJSEntityDescription( - # NotificationType 1: Smoke Alarm - State Id's 1 and 2 - Smoke detected - key=NOTIFICATION_SMOKE_ALARM, - states=("1", "2"), - device_class=BinarySensorDeviceClass.SMOKE, - ), - NotificationZWaveJSEntityDescription( - # NotificationType 1: Smoke Alarm - State Id's 4, 5, 7, 8 - key=NOTIFICATION_SMOKE_ALARM, - states=("4", "5", "7", "8"), - device_class=BinarySensorDeviceClass.PROBLEM, - entity_category=EntityCategory.DIAGNOSTIC, - ), - NotificationZWaveJSEntityDescription( - # NotificationType 1: Smoke Alarm - All other State Id's - key=NOTIFICATION_SMOKE_ALARM, - entity_category=EntityCategory.DIAGNOSTIC, - ), NotificationZWaveJSEntityDescription( # NotificationType 2: Carbon Monoxide - State Id's 1 and 2 key=NOTIFICATION_CARBON_MONOOXIDE, @@ -212,8 +207,8 @@ class PropertyZWaveJSEntityDescription(BinarySensorEntityDescription): NotificationZWaveJSEntityDescription( # NotificationType 6: Access Control - State Id 22 (door/window open) key=NOTIFICATION_ACCESS_CONTROL, - off_state="23", - states=("22", "23"), + not_states={"23"}, + states=("22",), device_class=BinarySensorDeviceClass.DOOR, ), NotificationZWaveJSEntityDescription( @@ -245,8 +240,8 @@ class PropertyZWaveJSEntityDescription(BinarySensorEntityDescription): # NotificationType 8: Power Management - # State Id's 2, 3 (Mains status) key=NOTIFICATION_POWER_MANAGEMENT, - off_state="2", - states=("2", "3"), + not_states={"2"}, + states=("3",), device_class=BinarySensorDeviceClass.PLUG, entity_category=EntityCategory.DIAGNOSTIC, ), @@ -353,7 +348,7 @@ class PropertyZWaveJSEntityDescription(BinarySensorEntityDescription): @callback def is_valid_notification_binary_sensor( - info: ZwaveDiscoveryInfo, + info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo, ) -> bool | NotificationZWaveJSEntityDescription: """Return if the notification CC Value is valid as binary sensor.""" if not info.primary_value.metadata.states: @@ -370,13 +365,36 @@ async def async_setup_entry( client = config_entry.runtime_data.client @callback - def async_add_binary_sensor(info: ZwaveDiscoveryInfo) -> None: + def async_add_binary_sensor( + info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo, + ) -> None: """Add Z-Wave Binary Sensor.""" driver = client.driver assert driver is not None # Driver is ready before platforms are loaded. - entities: list[BinarySensorEntity] = [] + entities: list[Entity] = [] - if info.platform_hint == "notification": + if ( + isinstance(info, NewZwaveDiscoveryInfo) + and info.entity_class is ZWaveNotificationBinarySensor + and isinstance( + info.entity_description, NotificationZWaveJSEntityDescription + ) + and is_valid_notification_binary_sensor(info) + ): + entities.extend( + ZWaveNotificationBinarySensor( + config_entry, driver, info, state_key, info.entity_description + ) + for state_key in info.primary_value.metadata.states + if state_key not in info.entity_description.not_states + and ( + not info.entity_description.states + or state_key in info.entity_description.states + ) + ) + elif isinstance(info, NewZwaveDiscoveryInfo): + pass # other entity classes are not migrated yet + elif info.platform_hint == "notification": # ensure the notification CC Value is valid as binary sensor if not is_valid_notification_binary_sensor(info): return @@ -401,7 +419,7 @@ def async_add_binary_sensor(info: ZwaveDiscoveryInfo) -> None: if ( notification_description - and notification_description.off_state == state_key + and state_key in notification_description.not_states ): continue entities.append( @@ -477,7 +495,7 @@ def __init__( self, config_entry: ZwaveJSConfigEntry, driver: Driver, - info: ZwaveDiscoveryInfo, + info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo, state_key: str, description: NotificationZWaveJSEntityDescription | None = None, ) -> None: @@ -543,3 +561,71 @@ def __init__( alternate_value_name=self.info.primary_value.property_name, additional_info=[property_key_name] if property_key_name else None, ) + + +DISCOVERY_SCHEMAS: list[NewZWaveDiscoverySchema] = [ + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={ + CommandClass.NOTIFICATION, + }, + type={ValueType.NUMBER}, + any_available_states_keys={1, 2}, + any_available_cc_specific={(CC_SPECIFIC_NOTIFICATION_TYPE, 1)}, + ), + allow_multi=True, + entity_description=NotificationZWaveJSEntityDescription( + # NotificationType 1: Smoke Alarm - State Id's 1 and 2 - Smoke detected + key=NOTIFICATION_SMOKE_ALARM, + states=("1", "2"), + device_class=BinarySensorDeviceClass.SMOKE, + ), + entity_class=ZWaveNotificationBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={ + CommandClass.NOTIFICATION, + }, + type={ValueType.NUMBER}, + any_available_states_keys={4, 5, 7, 8}, + any_available_cc_specific={(CC_SPECIFIC_NOTIFICATION_TYPE, 1)}, + ), + allow_multi=True, + entity_description=NotificationZWaveJSEntityDescription( + # NotificationType 1: Smoke Alarm - State Id's 4, 5, 7, 8 + key=NOTIFICATION_SMOKE_ALARM, + states=("4", "5", "7", "8"), + device_class=BinarySensorDeviceClass.PROBLEM, + entity_category=EntityCategory.DIAGNOSTIC, + ), + entity_class=ZWaveNotificationBinarySensor, + ), + NewZWaveDiscoverySchema( + platform=Platform.BINARY_SENSOR, + primary_value=ZWaveValueDiscoverySchema( + command_class={ + CommandClass.NOTIFICATION, + }, + type={ValueType.NUMBER}, + any_available_cc_specific={(CC_SPECIFIC_NOTIFICATION_TYPE, 1)}, + ), + allow_multi=True, + entity_description=NotificationZWaveJSEntityDescription( + # NotificationType 1: Smoke Alarm - All other State Id's + key=NOTIFICATION_SMOKE_ALARM, + entity_category=EntityCategory.DIAGNOSTIC, + not_states={ + "1", + "2", + "4", + "5", + "7", + "8", + }, + ), + entity_class=ZWaveNotificationBinarySensor, + ), +] diff --git a/homeassistant/components/zwave_js/cover.py b/homeassistant/components/zwave_js/cover.py index 424fe94b8b9af..d468a233f0500 100644 --- a/homeassistant/components/zwave_js/cover.py +++ b/homeassistant/components/zwave_js/cover.py @@ -299,11 +299,23 @@ def __init__( # Entity class attributes self._attr_device_class = CoverDeviceClass.WINDOW - if self.info.platform_hint and self.info.platform_hint.startswith("shutter"): + if ( + isinstance(self.info, ZwaveDiscoveryInfo) + and self.info.platform_hint + and self.info.platform_hint.startswith("shutter") + ): self._attr_device_class = CoverDeviceClass.SHUTTER - elif self.info.platform_hint and self.info.platform_hint.startswith("blind"): + elif ( + isinstance(self.info, ZwaveDiscoveryInfo) + and self.info.platform_hint + and self.info.platform_hint.startswith("blind") + ): self._attr_device_class = CoverDeviceClass.BLIND - elif self.info.platform_hint and self.info.platform_hint.startswith("gate"): + elif ( + isinstance(self.info, ZwaveDiscoveryInfo) + and self.info.platform_hint + and self.info.platform_hint.startswith("gate") + ): self._attr_device_class = CoverDeviceClass.GATE diff --git a/homeassistant/components/zwave_js/discovery.py b/homeassistant/components/zwave_js/discovery.py index 7030009f5ade5..858e4c300b8eb 100644 --- a/homeassistant/components/zwave_js/discovery.py +++ b/homeassistant/components/zwave_js/discovery.py @@ -3,9 +3,8 @@ from __future__ import annotations from collections.abc import Generator -from dataclasses import asdict, dataclass, field -from enum import StrEnum -from typing import TYPE_CHECKING, Any, cast +from dataclasses import dataclass +from typing import cast from awesomeversion import AwesomeVersion from zwave_js_server.const import ( @@ -55,6 +54,7 @@ from homeassistant.core import callback from homeassistant.helpers.device_registry import DeviceEntry +from .binary_sensor import DISCOVERY_SCHEMAS as BINARY_SENSOR_SCHEMAS from .const import COVER_POSITION_PROPERTY_KEYS, COVER_TILT_PROPERTY_KEYS, LOGGER from .discovery_data_template import ( BaseDiscoverySchemaDataTemplate, @@ -65,108 +65,20 @@ FixedFanValueMappingDataTemplate, NumericSensorDataTemplate, ) -from .helpers import ZwaveValueID - -if TYPE_CHECKING: - from _typeshed import DataclassInstance - - -class ValueType(StrEnum): - """Enum with all value types.""" - - ANY = "any" - BOOLEAN = "boolean" - NUMBER = "number" - STRING = "string" - - -class DataclassMustHaveAtLeastOne: - """A dataclass that must have at least one input parameter that is not None.""" - - def __post_init__(self: DataclassInstance) -> None: - """Post dataclass initialization.""" - if all(val is None for val in asdict(self).values()): - raise ValueError("At least one input parameter must not be None") - - -@dataclass -class FirmwareVersionRange(DataclassMustHaveAtLeastOne): - """Firmware version range dictionary.""" - - min: str | None = None - max: str | None = None - min_ver: AwesomeVersion | None = field(default=None, init=False) - max_ver: AwesomeVersion | None = field(default=None, init=False) - - def __post_init__(self) -> None: - """Post dataclass initialization.""" - super().__post_init__() - if self.min: - self.min_ver = AwesomeVersion(self.min) - if self.max: - self.max_ver = AwesomeVersion(self.max) - - -@dataclass -class ZwaveDiscoveryInfo: - """Info discovered from (primary) ZWave Value to create entity.""" - - # node to which the value(s) belongs - node: ZwaveNode - # the value object itself for primary value - primary_value: ZwaveValue - # bool to specify whether state is assumed and events should be fired on value - # update - assumed_state: bool - # the home assistant platform for which an entity should be created - platform: Platform - # helper data to use in platform setup - platform_data: Any - # additional values that need to be watched by entity - additional_value_ids_to_watch: set[str] - # hint for the platform about this discovered entity - platform_hint: str | None = "" - # data template to use in platform logic - platform_data_template: BaseDiscoverySchemaDataTemplate | None = None - # bool to specify whether entity should be enabled by default - entity_registry_enabled_default: bool = True - # the entity category for the discovered entity - entity_category: EntityCategory | None = None - - -@dataclass -class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): - """Z-Wave Value discovery schema. - - The Z-Wave Value must match these conditions. - Use the Z-Wave specifications to find out the values for these parameters: - https://github.com/zwave-js/specs/tree/master - """ +from .entity import NewZwaveDiscoveryInfo +from .models import ( + FirmwareVersionRange, + NewZWaveDiscoverySchema, + ValueType, + ZwaveDiscoveryInfo, + ZWaveValueDiscoverySchema, + ZwaveValueID, +) - # [optional] the value's command class must match ANY of these values - command_class: set[int] | None = None - # [optional] the value's endpoint must match ANY of these values - endpoint: set[int] | None = None - # [optional] the value's property must match ANY of these values - property: set[str | int] | None = None - # [optional] the value's property name must match ANY of these values - property_name: set[str] | None = None - # [optional] the value's property key must match ANY of these values - property_key: set[str | int | None] | None = None - # [optional] the value's property key must NOT match ANY of these values - not_property_key: set[str | int | None] | None = None - # [optional] the value's metadata_type must match ANY of these values - type: set[str] | None = None - # [optional] the value's metadata_readable must match this value - readable: bool | None = None - # [optional] the value's metadata_writeable must match this value - writeable: bool | None = None - # [optional] the value's states map must include ANY of these key/value pairs - any_available_states: set[tuple[int, str]] | None = None - # [optional] the value's value must match this value - value: Any | None = None - # [optional] the value's metadata_stateful must match this value - stateful: bool | None = None +NEW_DISCOVERY_SCHEMAS: dict[Platform, list[NewZWaveDiscoverySchema]] = { + Platform.BINARY_SENSOR: BINARY_SENSOR_SCHEMAS, +} +SUPPORTED_PLATFORMS = tuple(NEW_DISCOVERY_SCHEMAS) @dataclass @@ -1316,7 +1228,7 @@ class ZWaveDiscoverySchema: @callback def async_discover_node_values( node: ZwaveNode, device: DeviceEntry, discovered_value_ids: dict[str, set[str]] -) -> Generator[ZwaveDiscoveryInfo]: +) -> Generator[ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo]: """Run discovery on ZWave node and return matching (primary) values.""" for value in node.values.values(): # We don't need to rediscover an already processed value_id @@ -1327,9 +1239,19 @@ def async_discover_node_values( @callback def async_discover_single_value( value: ZwaveValue, device: DeviceEntry, discovered_value_ids: dict[str, set[str]] -) -> Generator[ZwaveDiscoveryInfo]: +) -> Generator[ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo]: """Run discovery on a single ZWave value and return matching schema info.""" - for schema in DISCOVERY_SCHEMAS: + # Temporary workaround for new schemas + schemas: tuple[ZWaveDiscoverySchema | NewZWaveDiscoverySchema, ...] = ( + *( + new_schema + for _schemas in NEW_DISCOVERY_SCHEMAS.values() + for new_schema in _schemas + ), + *DISCOVERY_SCHEMAS, + ) + + for schema in schemas: # abort if attribute(s) already discovered if value.value_id in discovered_value_ids[device.id]: continue @@ -1458,18 +1380,38 @@ def async_discover_single_value( ) # all checks passed, this value belongs to an entity - yield ZwaveDiscoveryInfo( - node=value.node, - primary_value=value, - assumed_state=schema.assumed_state, - platform=schema.platform, - platform_hint=schema.hint, - platform_data_template=schema.data_template, - platform_data=resolved_data, - additional_value_ids_to_watch=additional_value_ids_to_watch, - entity_registry_enabled_default=schema.entity_registry_enabled_default, - entity_category=schema.entity_category, - ) + + discovery_info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo + + # Temporary workaround for new schemas + if isinstance(schema, NewZWaveDiscoverySchema): + discovery_info = NewZwaveDiscoveryInfo( + node=value.node, + primary_value=value, + assumed_state=schema.assumed_state, + platform=schema.platform, + platform_data_template=schema.data_template, + platform_data=resolved_data, + additional_value_ids_to_watch=additional_value_ids_to_watch, + entity_class=schema.entity_class, + entity_description=schema.entity_description, + ) + + else: + discovery_info = ZwaveDiscoveryInfo( + node=value.node, + primary_value=value, + assumed_state=schema.assumed_state, + platform=schema.platform, + platform_hint=schema.hint, + platform_data_template=schema.data_template, + platform_data=resolved_data, + additional_value_ids_to_watch=additional_value_ids_to_watch, + entity_registry_enabled_default=schema.entity_registry_enabled_default, + entity_category=schema.entity_category, + ) + + yield discovery_info # prevent re-discovery of the (primary) value if not allowed if not schema.allow_multi: @@ -1615,6 +1557,25 @@ def check_value( ) ): return False + if ( + schema.any_available_states_keys is not None + and value.metadata.states is not None + and not any( + str(key) in value.metadata.states + for key in schema.any_available_states_keys + ) + ): + return False + # check available cc specific + if ( + schema.any_available_cc_specific is not None + and value.metadata.cc_specific is not None + and not any( + key in value.metadata.cc_specific and value.metadata.cc_specific[key] == val + for key, val in schema.any_available_cc_specific + ) + ): + return False # check value if schema.value is not None and value.value not in schema.value: return False diff --git a/homeassistant/components/zwave_js/discovery_data_template.py b/homeassistant/components/zwave_js/discovery_data_template.py index 731a786d22622..8fbc5f35555ce 100644 --- a/homeassistant/components/zwave_js/discovery_data_template.py +++ b/homeassistant/components/zwave_js/discovery_data_template.py @@ -90,11 +90,9 @@ MultilevelSensorType, ) from zwave_js_server.exceptions import UnknownValueData -from zwave_js_server.model.node import Node as ZwaveNode from zwave_js_server.model.value import ( ConfigurationValue as ZwaveConfigurationValue, Value as ZwaveValue, - get_value_id_str, ) from zwave_js_server.util.command_class.energy_production import ( get_energy_production_parameter, @@ -159,7 +157,7 @@ ENTITY_DESC_KEY_UV_INDEX, ENTITY_DESC_KEY_VOLTAGE, ) -from .helpers import ZwaveValueID +from .models import BaseDiscoverySchemaDataTemplate, ZwaveValueID ENERGY_PRODUCTION_DEVICE_CLASS_MAP: dict[str, list[EnergyProductionParameter]] = { ENTITY_DESC_KEY_ENERGY_PRODUCTION_TIME: [EnergyProductionParameter.TOTAL_TIME], @@ -264,49 +262,6 @@ _LOGGER = logging.getLogger(__name__) -@dataclass -class BaseDiscoverySchemaDataTemplate: - """Base class for discovery schema data templates.""" - - static_data: Any | None = None - - def resolve_data(self, value: ZwaveValue) -> Any: - """Resolve helper class data for a discovered value. - - Can optionally be implemented by subclasses if input data needs to be - transformed once discovered Value is available. - """ - return {} - - def values_to_watch(self, resolved_data: Any) -> Iterable[ZwaveValue | None]: - """Return list of all ZwaveValues resolved by helper that should be watched. - - Should be implemented by subclasses only if there are values to watch. - """ - return [] - - def value_ids_to_watch(self, resolved_data: Any) -> set[str]: - """Return list of all Value IDs resolved by helper that should be watched. - - Not to be overwritten by subclasses. - """ - return {val.value_id for val in self.values_to_watch(resolved_data) if val} - - @staticmethod - def _get_value_from_id( - node: ZwaveNode, value_id_obj: ZwaveValueID - ) -> ZwaveValue | ZwaveConfigurationValue | None: - """Get a ZwaveValue from a node using a ZwaveValueDict.""" - value_id = get_value_id_str( - node, - value_id_obj.command_class, - value_id_obj.property_, - endpoint=value_id_obj.endpoint, - property_key=value_id_obj.property_key, - ) - return node.values.get(value_id) - - @dataclass class DynamicCurrentTempClimateDataTemplate(BaseDiscoverySchemaDataTemplate): """Data template class for Z-Wave JS Climate entities with dynamic current temps.""" diff --git a/homeassistant/components/zwave_js/entity.py b/homeassistant/components/zwave_js/entity.py index 08a587d8d20cc..ab892565c0f6c 100644 --- a/homeassistant/components/zwave_js/entity.py +++ b/homeassistant/components/zwave_js/entity.py @@ -3,6 +3,7 @@ from __future__ import annotations from collections.abc import Sequence +from dataclasses import dataclass from typing import Any from zwave_js_server.exceptions import BaseZwaveJSServerError @@ -18,16 +19,33 @@ from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.dispatcher import async_dispatcher_connect -from homeassistant.helpers.entity import Entity +from homeassistant.helpers.entity import Entity, EntityDescription from homeassistant.helpers.typing import UNDEFINED from .const import DOMAIN, EVENT_VALUE_UPDATED, LOGGER -from .discovery import ZwaveDiscoveryInfo +from .discovery_data_template import BaseDiscoverySchemaDataTemplate from .helpers import get_device_id, get_unique_id, get_valueless_base_unique_id +from .models import PlatformZwaveDiscoveryInfo, ZwaveDiscoveryInfo EVENT_VALUE_REMOVED = "value removed" +@dataclass(kw_only=True) +class NewZwaveDiscoveryInfo(PlatformZwaveDiscoveryInfo): + """Info discovered from (primary) ZWave Value to create entity. + + This is the new discovery info that will replace ZwaveDiscoveryInfo. + """ + + entity_class: type[ZWaveBaseEntity] + # the entity description to use + entity_description: EntityDescription + # helper data to use in platform setup + platform_data: Any = None + # data template to use in platform logic + platform_data_template: BaseDiscoverySchemaDataTemplate | None = None + + class ZWaveBaseEntity(Entity): """Generic Entity Class for a Z-Wave Device.""" @@ -35,7 +53,10 @@ class ZWaveBaseEntity(Entity): _attr_has_entity_name = True def __init__( - self, config_entry: ConfigEntry, driver: Driver, info: ZwaveDiscoveryInfo + self, + config_entry: ConfigEntry, + driver: Driver, + info: ZwaveDiscoveryInfo | NewZwaveDiscoveryInfo, ) -> None: """Initialize a generic Z-Wave device entity.""" self.config_entry = config_entry @@ -52,12 +73,14 @@ def __init__( # Entity class attributes self._attr_name = self.generate_name() self._attr_unique_id = get_unique_id(driver, self.info.primary_value.value_id) - if self.info.entity_registry_enabled_default is False: - self._attr_entity_registry_enabled_default = False - if self.info.entity_category is not None: - self._attr_entity_category = self.info.entity_category - if self.info.assumed_state: - self._attr_assumed_state = True + if isinstance(info, NewZwaveDiscoveryInfo): + self.entity_description = info.entity_description + else: + if (enabled_default := info.entity_registry_enabled_default) is False: + self._attr_entity_registry_enabled_default = enabled_default + if (entity_category := info.entity_category) is not None: + self._attr_entity_category = entity_category + self._attr_assumed_state = self.info.assumed_state # device is precreated in main handler self._attr_device_info = DeviceInfo( identifiers={get_device_id(driver, self.info.node)}, diff --git a/homeassistant/components/zwave_js/helpers.py b/homeassistant/components/zwave_js/helpers.py index 17f4909662cdd..dc415c157b60a 100644 --- a/homeassistant/components/zwave_js/helpers.py +++ b/homeassistant/components/zwave_js/helpers.py @@ -60,16 +60,6 @@ SERVER_VERSION_TIMEOUT = 10 -@dataclass -class ZwaveValueID: - """Class to represent a value ID.""" - - property_: str | int - command_class: int - endpoint: int | None = None - property_key: str | int | None = None - - @dataclass class ZwaveValueMatcher: """Class to allow matching a Z-Wave Value.""" diff --git a/homeassistant/components/zwave_js/migrate.py b/homeassistant/components/zwave_js/migrate.py index ac749cb516b5f..e4cd414a2bb7d 100644 --- a/homeassistant/components/zwave_js/migrate.py +++ b/homeassistant/components/zwave_js/migrate.py @@ -5,6 +5,7 @@ from dataclasses import dataclass import logging +from zwave_js_server.const import CommandClass from zwave_js_server.model.driver import Driver from zwave_js_server.model.node import Node from zwave_js_server.model.value import Value as ZwaveValue @@ -14,8 +15,8 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er from .const import DOMAIN -from .discovery import ZwaveDiscoveryInfo from .helpers import get_unique_id, get_valueless_base_unique_id +from .models import PlatformZwaveDiscoveryInfo _LOGGER = logging.getLogger(__name__) @@ -140,7 +141,7 @@ def async_migrate_discovered_value( registered_unique_ids: set[str], device: dr.DeviceEntry, driver: Driver, - disc_info: ZwaveDiscoveryInfo, + disc_info: PlatformZwaveDiscoveryInfo, ) -> None: """Migrate unique ID for entity/entities tied to discovered value.""" @@ -162,7 +163,7 @@ def async_migrate_discovered_value( if ( disc_info.platform == Platform.BINARY_SENSOR - and disc_info.platform_hint == "notification" + and disc_info.primary_value.command_class == CommandClass.NOTIFICATION ): for state_key in disc_info.primary_value.metadata.states: # ignore idle key (0) diff --git a/homeassistant/components/zwave_js/models.py b/homeassistant/components/zwave_js/models.py index 63f77871c141d..ba93be7a554c5 100644 --- a/homeassistant/components/zwave_js/models.py +++ b/homeassistant/components/zwave_js/models.py @@ -1,15 +1,27 @@ -"""Type definitions for Z-Wave JS integration.""" +"""Provide models for the Z-Wave integration.""" from __future__ import annotations -from dataclasses import dataclass -from typing import TYPE_CHECKING +from collections.abc import Iterable +from dataclasses import asdict, dataclass, field +from enum import StrEnum +from typing import TYPE_CHECKING, Any +from awesomeversion import AwesomeVersion from zwave_js_server.const import LogLevel +from zwave_js_server.model.node import Node as ZwaveNode +from zwave_js_server.model.value import ( + ConfigurationValue as ZwaveConfigurationValue, + Value as ZwaveValue, + get_value_id_str, +) from homeassistant.config_entries import ConfigEntry +from homeassistant.const import EntityCategory, Platform +from homeassistant.helpers.entity import EntityDescription if TYPE_CHECKING: + from _typeshed import DataclassInstance from zwave_js_server.client import Client as ZwaveClient from . import DriverEvents @@ -25,3 +37,213 @@ class ZwaveJSData: type ZwaveJSConfigEntry = ConfigEntry[ZwaveJSData] + + +@dataclass +class ZwaveValueID: + """Class to represent a value ID.""" + + property_: str | int + command_class: int + endpoint: int | None = None + property_key: str | int | None = None + + +class ValueType(StrEnum): + """Enum with all value types.""" + + ANY = "any" + BOOLEAN = "boolean" + NUMBER = "number" + STRING = "string" + + +class DataclassMustHaveAtLeastOne: + """A dataclass that must have at least one input parameter that is not None.""" + + def __post_init__(self: DataclassInstance) -> None: + """Post dataclass initialization.""" + if all(val is None for val in asdict(self).values()): + raise ValueError("At least one input parameter must not be None") + + +@dataclass +class FirmwareVersionRange(DataclassMustHaveAtLeastOne): + """Firmware version range dictionary.""" + + min: str | None = None + max: str | None = None + min_ver: AwesomeVersion | None = field(default=None, init=False) + max_ver: AwesomeVersion | None = field(default=None, init=False) + + def __post_init__(self) -> None: + """Post dataclass initialization.""" + super().__post_init__() + if self.min: + self.min_ver = AwesomeVersion(self.min) + if self.max: + self.max_ver = AwesomeVersion(self.max) + + +@dataclass +class PlatformZwaveDiscoveryInfo: + """Info discovered from (primary) ZWave Value to create entity.""" + + # node to which the value(s) belongs + node: ZwaveNode + # the value object itself for primary value + primary_value: ZwaveValue + # bool to specify whether state is assumed and events should be fired on value + # update + assumed_state: bool + # the home assistant platform for which an entity should be created + platform: Platform + # additional values that need to be watched by entity + additional_value_ids_to_watch: set[str] + + +@dataclass +class ZwaveDiscoveryInfo(PlatformZwaveDiscoveryInfo): + """Info discovered from (primary) ZWave Value to create entity.""" + + # helper data to use in platform setup + platform_data: Any = None + # data template to use in platform logic + platform_data_template: BaseDiscoverySchemaDataTemplate | None = None + # hint for the platform about this discovered entity + platform_hint: str | None = "" + # bool to specify whether entity should be enabled by default + entity_registry_enabled_default: bool = True + # the entity category for the discovered entity + entity_category: EntityCategory | None = None + + +@dataclass +class ZWaveValueDiscoverySchema(DataclassMustHaveAtLeastOne): + """Z-Wave Value discovery schema. + + The Z-Wave Value must match these conditions. + Use the Z-Wave specifications to find out the values for these parameters: + https://github.com/zwave-js/specs/tree/master + """ + + # [optional] the value's command class must match ANY of these values + command_class: set[int] | None = None + # [optional] the value's endpoint must match ANY of these values + endpoint: set[int] | None = None + # [optional] the value's property must match ANY of these values + property: set[str | int] | None = None + # [optional] the value's property name must match ANY of these values + property_name: set[str] | None = None + # [optional] the value's property key must match ANY of these values + property_key: set[str | int | None] | None = None + # [optional] the value's property key must NOT match ANY of these values + not_property_key: set[str | int | None] | None = None + # [optional] the value's metadata_type must match ANY of these values + type: set[str] | None = None + # [optional] the value's metadata_readable must match this value + readable: bool | None = None + # [optional] the value's metadata_writeable must match this value + writeable: bool | None = None + # [optional] the value's states map must include ANY of these key/value pairs + any_available_states: set[tuple[int, str]] | None = None + # [optional] the value's states map must include ANY of these keys + any_available_states_keys: set[int] | None = None + # [optional] the value's cc specific map must include ANY of these key/value pairs + any_available_cc_specific: set[tuple[Any, Any]] | None = None + # [optional] the value's value must match this value + value: Any | None = None + # [optional] the value's metadata_stateful must match this value + stateful: bool | None = None + + +@dataclass +class NewZWaveDiscoverySchema: + """Z-Wave discovery schema. + + The Z-Wave node and it's (primary) value for an entity must match these conditions. + Use the Z-Wave specifications to find out the values for these parameters: + https://github.com/zwave-js/node-zwave-js/tree/master/specs + """ + + # specify the hass platform for which this scheme applies (e.g. light, sensor) + platform: Platform + # platform-specific entity description + entity_description: EntityDescription + # entity class to use to instantiate the entity + entity_class: type + # primary value belonging to this discovery scheme + primary_value: ZWaveValueDiscoverySchema + # [optional] template to generate platform specific data to use in setup + data_template: BaseDiscoverySchemaDataTemplate | None = None + # [optional] the node's manufacturer_id must match ANY of these values + manufacturer_id: set[int] | None = None + # [optional] the node's product_id must match ANY of these values + product_id: set[int] | None = None + # [optional] the node's product_type must match ANY of these values + product_type: set[int] | None = None + # [optional] the node's firmware_version must be within this range + firmware_version_range: FirmwareVersionRange | None = None + # [optional] the node's firmware_version must match ANY of these values + firmware_version: set[str] | None = None + # [optional] the node's basic device class must match ANY of these values + device_class_basic: set[str | int] | None = None + # [optional] the node's generic device class must match ANY of these values + device_class_generic: set[str | int] | None = None + # [optional] the node's specific device class must match ANY of these values + device_class_specific: set[str | int] | None = None + # [optional] additional values that ALL need to be present + # on the node for this scheme to pass + required_values: list[ZWaveValueDiscoverySchema] | None = None + # [optional] additional values that MAY NOT be present + # on the node for this scheme to pass + absent_values: list[ZWaveValueDiscoverySchema] | None = None + # [optional] bool to specify if this primary value may be discovered + # by multiple platforms + allow_multi: bool = False + # [optional] bool to specify whether state is assumed + # and events should be fired on value update + assumed_state: bool = False + + +@dataclass +class BaseDiscoverySchemaDataTemplate: + """Base class for discovery schema data templates.""" + + static_data: Any | None = None + + def resolve_data(self, value: ZwaveValue) -> Any: + """Resolve helper class data for a discovered value. + + Can optionally be implemented by subclasses if input data needs to be + transformed once discovered Value is available. + """ + return {} + + def values_to_watch(self, resolved_data: Any) -> Iterable[ZwaveValue | None]: + """Return list of all ZwaveValues resolved by helper that should be watched. + + Should be implemented by subclasses only if there are values to watch. + """ + return [] + + def value_ids_to_watch(self, resolved_data: Any) -> set[str]: + """Return list of all Value IDs resolved by helper that should be watched. + + Not to be overwritten by subclasses. + """ + return {val.value_id for val in self.values_to_watch(resolved_data) if val} + + @staticmethod + def _get_value_from_id( + node: ZwaveNode, value_id_obj: ZwaveValueID + ) -> ZwaveValue | ZwaveConfigurationValue | None: + """Get a ZwaveValue from a node using a ZwaveValueDict.""" + value_id = get_value_id_str( + node, + value_id_obj.command_class, + value_id_obj.property_, + endpoint=value_id_obj.endpoint, + property_key=value_id_obj.property_key, + ) + return node.values.get(value_id) From 4821c9ec29f4fc02e98306e1489abacf272906f6 Mon Sep 17 00:00:00 2001 From: karwosts <32912880+karwosts@users.noreply.github.com> Date: Wed, 27 Aug 2025 03:39:33 -0700 Subject: [PATCH 04/10] Use media_selector for media_player.play_media (#150721) --- .../components/media_player/__init__.py | 21 ++++++++ .../components/media_player/services.yaml | 14 ++--- .../components/media_player/strings.json | 10 ++-- tests/components/media_player/test_init.py | 53 +++++++++++++++++++ 4 files changed, 82 insertions(+), 16 deletions(-) diff --git a/homeassistant/components/media_player/__init__.py b/homeassistant/components/media_player/__init__.py index b2cb7d76e8f65..01ff31e277c5e 100644 --- a/homeassistant/components/media_player/__init__.py +++ b/homeassistant/components/media_player/__init__.py @@ -161,6 +161,8 @@ CACHE_URL: Final = "url" CACHE_CONTENT: Final = "content" +ATTR_MEDIA = "media" + class MediaPlayerEnqueue(StrEnum): """Enqueue types for playing media.""" @@ -200,6 +202,24 @@ class MediaPlayerDeviceClass(StrEnum): DEVICE_CLASSES = [cls.value for cls in MediaPlayerDeviceClass] +def _promote_media_fields(data: dict[str, Any]) -> dict[str, Any]: + """If 'media' key exists, promote its fields to the top level.""" + if ATTR_MEDIA in data and isinstance(data[ATTR_MEDIA], dict): + if ATTR_MEDIA_CONTENT_TYPE in data or ATTR_MEDIA_CONTENT_ID in data: + raise vol.Invalid( + f"Play media cannot contain '{ATTR_MEDIA}' and '{ATTR_MEDIA_CONTENT_ID}' or '{ATTR_MEDIA_CONTENT_TYPE}'" + ) + media_data = data[ATTR_MEDIA] + + if ATTR_MEDIA_CONTENT_TYPE in media_data: + data[ATTR_MEDIA_CONTENT_TYPE] = media_data[ATTR_MEDIA_CONTENT_TYPE] + if ATTR_MEDIA_CONTENT_ID in media_data: + data[ATTR_MEDIA_CONTENT_ID] = media_data[ATTR_MEDIA_CONTENT_ID] + + del data[ATTR_MEDIA] + return data + + MEDIA_PLAYER_PLAY_MEDIA_SCHEMA = { vol.Required(ATTR_MEDIA_CONTENT_TYPE): cv.string, vol.Required(ATTR_MEDIA_CONTENT_ID): cv.string, @@ -436,6 +456,7 @@ def _rewrite_enqueue(value: dict[str, Any]) -> dict[str, Any]: component.async_register_entity_service( SERVICE_PLAY_MEDIA, vol.All( + _promote_media_fields, cv.make_entity_service_schema(MEDIA_PLAYER_PLAY_MEDIA_SCHEMA), _rewrite_enqueue, _rename_keys( diff --git a/homeassistant/components/media_player/services.yaml b/homeassistant/components/media_player/services.yaml index ac359de1a5be4..24a04393d9466 100644 --- a/homeassistant/components/media_player/services.yaml +++ b/homeassistant/components/media_player/services.yaml @@ -131,17 +131,13 @@ play_media: supported_features: - media_player.MediaPlayerEntityFeature.PLAY_MEDIA fields: - media_content_id: - required: true - example: "https://home-assistant.io/images/cast/splash.png" - selector: - text: - - media_content_type: + media: required: true - example: "music" selector: - text: + media: + example: + media_content_id: "https://home-assistant.io/images/cast/splash.png" + media_content_type: "music" enqueue: filter: diff --git a/homeassistant/components/media_player/strings.json b/homeassistant/components/media_player/strings.json index 617cb258af71f..c3b96a5250e26 100644 --- a/homeassistant/components/media_player/strings.json +++ b/homeassistant/components/media_player/strings.json @@ -242,13 +242,9 @@ "name": "Play media", "description": "Starts playing specified media.", "fields": { - "media_content_id": { - "name": "Content ID", - "description": "The ID of the content to play. Platform dependent." - }, - "media_content_type": { - "name": "Content type", - "description": "The type of the content to play, such as image, music, tv show, video, episode, channel, or playlist." + "media": { + "name": "Media", + "description": "The media selected to play." }, "enqueue": { "name": "Enqueue", diff --git a/tests/components/media_player/test_init.py b/tests/components/media_player/test_init.py index 2e270eb3b2e1f..552a94e872314 100644 --- a/tests/components/media_player/test_init.py +++ b/tests/components/media_player/test_init.py @@ -654,3 +654,56 @@ async def test_get_async_get_browse_image_quoting( url = player.get_browse_image_url("album", media_content_id) await client.get(url) mock_browse_image.assert_called_with("album", media_content_id, None) + + +async def test_play_media_via_selector(hass: HomeAssistant) -> None: + """Test that play_media data under 'media' is remapped to top level keys for backward compatibility.""" + await async_setup_component( + hass, "media_player", {"media_player": {"platform": "demo"}} + ) + await hass.async_block_till_done() + + # Fake group support for DemoYoutubePlayer + with patch( + "homeassistant.components.demo.media_player.DemoYoutubePlayer.play_media", + ) as mock_play_media: + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": "media_player.bedroom", + "media": { + "media_content_type": "music", + "media_content_id": "1234", + }, + }, + blocking=True, + ) + await hass.services.async_call( + "media_player", + "play_media", + { + "entity_id": "media_player.bedroom", + "media_content_type": "music", + "media_content_id": "1234", + }, + blocking=True, + ) + + assert len(mock_play_media.mock_calls) == 2 + assert mock_play_media.mock_calls[0].args == mock_play_media.mock_calls[1].args + + with pytest.raises(vol.Invalid, match="Play media cannot contain 'media'"): + await hass.services.async_call( + "media_player", + "play_media", + { + "media_content_id": "1234", + "entity_id": "media_player.bedroom", + "media": { + "media_content_type": "music", + "media_content_id": "1234", + }, + }, + blocking=True, + ) From ad37e00d1d03777b31e8d0758a340f0eea0fafe3 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Wed, 27 Aug 2025 14:04:01 +0200 Subject: [PATCH 05/10] Bump actions/ai-inference from 2.0.0 to 2.0.1 (#151147) Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- .github/workflows/detect-duplicate-issues.yml | 2 +- .github/workflows/detect-non-english-issues.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/detect-duplicate-issues.yml b/.github/workflows/detect-duplicate-issues.yml index 5f9522e05936c..7d2bb78cbffe1 100644 --- a/.github/workflows/detect-duplicate-issues.yml +++ b/.github/workflows/detect-duplicate-issues.yml @@ -231,7 +231,7 @@ jobs: - name: Detect duplicates using AI id: ai_detection if: steps.extract.outputs.should_continue == 'true' && steps.fetch_similar.outputs.has_similar == 'true' - uses: actions/ai-inference@v2.0.0 + uses: actions/ai-inference@v2.0.1 with: model: openai/gpt-4o system-prompt: | diff --git a/.github/workflows/detect-non-english-issues.yml b/.github/workflows/detect-non-english-issues.yml index bcad572696888..69718fd4421d0 100644 --- a/.github/workflows/detect-non-english-issues.yml +++ b/.github/workflows/detect-non-english-issues.yml @@ -57,7 +57,7 @@ jobs: - name: Detect language using AI id: ai_language_detection if: steps.detect_language.outputs.should_continue == 'true' - uses: actions/ai-inference@v2.0.0 + uses: actions/ai-inference@v2.0.1 with: model: openai/gpt-4o-mini system-prompt: | From d0deb16c10cb495f250e5c033f9ea952b0aa7ef1 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 27 Aug 2025 15:46:17 +0200 Subject: [PATCH 06/10] Update frontend to 20250827.0 (#151237) --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 9fc80cf0e8a5c..98840d3be54a1 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250811.1"] + "requirements": ["home-assistant-frontend==20250827.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 70f121d8c98eb..cc8a8f52f7b62 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==5.1.0 hass-nabucasa==1.0.0 hassil==3.2.0 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250811.1 +home-assistant-frontend==20250827.0 home-assistant-intents==2025.7.30 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 4d702582df753..dfeec8fa0b144 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ hole==0.9.0 holidays==0.79 # homeassistant.components.frontend -home-assistant-frontend==20250811.1 +home-assistant-frontend==20250827.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 37a929bb8a06a..9d2a57820827f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1023,7 +1023,7 @@ hole==0.9.0 holidays==0.79 # homeassistant.components.frontend -home-assistant-frontend==20250811.1 +home-assistant-frontend==20250827.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 From 8f9167abbeda5d7e7c3243bf6677c4e0b4d781c3 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 27 Aug 2025 15:46:57 +0200 Subject: [PATCH 07/10] Followup async_migrate_entry fix for Alexa Devices (#151231) --- homeassistant/components/alexa_devices/__init__.py | 6 +++--- tests/components/alexa_devices/test_init.py | 5 ++++- 2 files changed, 7 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/alexa_devices/__init__.py b/homeassistant/components/alexa_devices/__init__.py index 7a267579f9892..7a4641bc51f96 100644 --- a/homeassistant/components/alexa_devices/__init__.py +++ b/homeassistant/components/alexa_devices/__init__.py @@ -5,7 +5,7 @@ from homeassistant.helpers import aiohttp_client, config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import _LOGGER, COUNTRY_DOMAINS, DOMAIN +from .const import _LOGGER, CONF_LOGIN_DATA, COUNTRY_DOMAINS, DOMAIN from .coordinator import AmazonConfigEntry, AmazonDevicesCoordinator from .services import async_setup_services @@ -51,9 +51,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AmazonConfigEntry) -> country = entry.data[CONF_COUNTRY] domain = COUNTRY_DOMAINS.get(country, country) - # Save domain and remove country + # Add site to login data new_data = entry.data.copy() - new_data.update({"site": f"https://www.amazon.{domain}"}) + new_data[CONF_LOGIN_DATA]["site"] = f"https://www.amazon.{domain}" hass.config_entries.async_update_entry( entry, data=new_data, version=1, minor_version=2 diff --git a/tests/components/alexa_devices/test_init.py b/tests/components/alexa_devices/test_init.py index e809f002321b1..7055f8482ccc1 100644 --- a/tests/components/alexa_devices/test_init.py +++ b/tests/components/alexa_devices/test_init.py @@ -58,4 +58,7 @@ async def test_migrate_entry( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 assert config_entry.state is ConfigEntryState.LOADED assert config_entry.minor_version == 2 - assert config_entry.data["site"] == f"https://www.amazon.{TEST_COUNTRY}" + assert ( + config_entry.data[CONF_LOGIN_DATA]["site"] + == f"https://www.amazon.{TEST_COUNTRY}" + ) From aac572c4579c3b60edf695338a0b1a444ecf31fa Mon Sep 17 00:00:00 2001 From: Thomas D <11554546+thomasddn@users.noreply.github.com> Date: Wed, 27 Aug 2025 17:02:12 +0200 Subject: [PATCH 08/10] Record scene activation for Qbus integration (#151232) --- homeassistant/components/qbus/scene.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/homeassistant/components/qbus/scene.py b/homeassistant/components/qbus/scene.py index 706fb089dde1b..4403fe28259c2 100644 --- a/homeassistant/components/qbus/scene.py +++ b/homeassistant/components/qbus/scene.py @@ -5,7 +5,7 @@ from qbusmqttapi.discovery import QbusMqttOutput from qbusmqttapi.state import QbusMqttState, StateAction, StateType -from homeassistant.components.scene import Scene +from homeassistant.components.scene import BaseScene from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -38,7 +38,7 @@ def _check_outputs() -> None: entry.async_on_unload(coordinator.async_add_listener(_check_outputs)) -class QbusScene(QbusEntity, Scene): +class QbusScene(QbusEntity, BaseScene): """Representation of a Qbus scene entity.""" def __init__(self, mqtt_output: QbusMqttOutput) -> None: @@ -48,7 +48,7 @@ def __init__(self, mqtt_output: QbusMqttOutput) -> None: self._attr_name = mqtt_output.name.title() - async def async_activate(self, **kwargs: Any) -> None: + async def _async_activate(self, **kwargs: Any) -> None: """Activate scene.""" state = QbusMqttState( id=self._mqtt_output.id, type=StateType.ACTION, action=StateAction.ACTIVE @@ -56,5 +56,4 @@ async def async_activate(self, **kwargs: Any) -> None: await self._async_publish_output_state(state) async def _handle_state_received(self, state: QbusMqttState) -> None: - # Nothing to do - pass + self._async_record_activation() From 22e70723f40a6a22bd4716d9d0d6f6f51c1e285a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 27 Aug 2025 17:05:30 +0200 Subject: [PATCH 09/10] Matter `SensitivityLevel` for Aqara Door and Window Sensor P2 (#151117) --- homeassistant/components/matter/select.py | 25 +++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/homeassistant/components/matter/select.py b/homeassistant/components/matter/select.py index 5d7a5363da037..92b451d52651f 100644 --- a/homeassistant/components/matter/select.py +++ b/homeassistant/components/matter/select.py @@ -502,4 +502,29 @@ def _update_from_device(self) -> None: clusters.PumpConfigurationAndControl.Attributes.OperationMode, ), ), + MatterDiscoverySchema( + platform=Platform.SELECT, + entity_description=MatterSelectEntityDescription( + key="AqaraBooleanStateConfigurationCurrentSensitivityLevel", + entity_category=EntityCategory.CONFIG, + translation_key="sensitivity_level", + options=["10 mm", "20 mm", "30 mm"], + device_to_ha={ + 0: "10 mm", # 10 mm => CurrentSensitivityLevel=0 / highest sensitivity level + 1: "20 mm", # 20 mm => CurrentSensitivityLevel=1 / medium sensitivity level + 2: "30 mm", # 30 mm => CurrentSensitivityLevel=2 / lowest sensitivity level + }.get, + ha_to_device={ + "10 mm": 0, + "20 mm": 1, + "30 mm": 2, + }.get, + ), + entity_class=MatterAttributeSelectEntity, + required_attributes=( + clusters.BooleanStateConfiguration.Attributes.CurrentSensitivityLevel, + ), + vendor_id=(4447,), + product_name=("Aqara Door and Window Sensor P2",), + ), ] From 2ef335f403d0a682f4d101072ec13f37648a79de Mon Sep 17 00:00:00 2001 From: Matthias Alphart Date: Wed, 27 Aug 2025 17:13:49 +0200 Subject: [PATCH 10/10] KNX: Support external scene activation recording (#151218) --- homeassistant/components/knx/scene.py | 13 +++++++++---- tests/components/knx/test_scene.py | 24 ++++++++++++++++++++++-- 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/homeassistant/components/knx/scene.py b/homeassistant/components/knx/scene.py index 39e627ca8ff67..bc997f617b30d 100644 --- a/homeassistant/components/knx/scene.py +++ b/homeassistant/components/knx/scene.py @@ -4,10 +4,10 @@ from typing import Any -from xknx.devices import Scene as XknxScene +from xknx.devices import Device as XknxDevice, Scene as XknxScene from homeassistant import config_entries -from homeassistant.components.scene import Scene +from homeassistant.components.scene import BaseScene from homeassistant.const import CONF_ENTITY_CATEGORY, CONF_NAME, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback @@ -31,7 +31,7 @@ async def async_setup_entry( async_add_entities(KNXScene(knx_module, entity_config) for entity_config in config) -class KNXScene(KnxYamlEntity, Scene): +class KNXScene(KnxYamlEntity, BaseScene): """Representation of a KNX scene.""" _device: XknxScene @@ -52,6 +52,11 @@ def __init__(self, knx_module: KNXModule, config: ConfigType) -> None: f"{self._device.scene_value.group_address}_{self._device.scene_number}" ) - async def async_activate(self, **kwargs: Any) -> None: + async def _async_activate(self, **kwargs: Any) -> None: """Activate the scene.""" await self._device.run() + + def after_update_callback(self, device: XknxDevice) -> None: + """Call after device was updated.""" + self._async_record_activation() + super().after_update_callback(device) diff --git a/tests/components/knx/test_scene.py b/tests/components/knx/test_scene.py index 8598ef0a627c1..7dc850b484394 100644 --- a/tests/components/knx/test_scene.py +++ b/tests/components/knx/test_scene.py @@ -8,6 +8,8 @@ from .conftest import KNXTestKit +from tests.common import async_capture_events + async def test_activate_knx_scene( hass: HomeAssistant, knx: KNXTestKit, entity_registry: er.EntityRegistry @@ -30,9 +32,27 @@ async def test_activate_knx_scene( assert entity.entity_category is EntityCategory.DIAGNOSTIC assert entity.unique_id == "1/1/1_24" + events = async_capture_events(hass, "state_changed") + + # activate scene from HA + await hass.services.async_call( + "scene", "turn_on", {"entity_id": "scene.test"}, blocking=True + ) + await knx.assert_write("1/1/1", (0x17,)) + assert len(events) == 1 + # consecutive call from HA await hass.services.async_call( "scene", "turn_on", {"entity_id": "scene.test"}, blocking=True ) - - # assert scene was called on bus await knx.assert_write("1/1/1", (0x17,)) + assert len(events) == 2 + + # scene activation from bus + await knx.receive_write("1/1/1", (0x17,)) + assert len(events) == 3 + # same scene number consecutive call + await knx.receive_write("1/1/1", (0x17,)) + assert len(events) == 4 + # different scene number - should not be recorded + await knx.receive_write("1/1/1", (0x00,)) + assert len(events) == 4