Compare commits

..

7 Commits

Author SHA1 Message Date
Abílio Costa
ad9efd6429 Merge branch 'dev' into llm_device_name 2025-09-22 10:29:46 +01:00
Joshua Leaper
4b7746ab51 Bump nessclient to 1.3.1 (#152700) 2025-09-22 11:01:04 +03:00
Abílio Costa
ca1c366f4f Remove unused var from llm helper (#152724) 2025-09-22 09:57:16 +02:00
epenet
de42ac14ac Drop unused hass argument from internal helper (#152733) 2025-09-22 09:56:52 +02:00
J. Nick Koston
7f7bd5a97f Bump aioesphomeapi to 41.5.0 (#152730) 2025-09-22 09:56:20 +02:00
abmantis
3b59a03dfa Add device name to llm exposed entities info 2025-09-22 00:57:35 +01:00
abmantis
78bf54de42 Remove unused var from llm helper 2025-09-22 00:55:48 +01:00
16 changed files with 126 additions and 175 deletions

View File

@@ -17,7 +17,7 @@
"mqtt": ["esphome/discover/#"],
"quality_scale": "platinum",
"requirements": [
"aioesphomeapi==41.4.0",
"aioesphomeapi==41.5.0",
"esphome-dashboard-api==1.3.0",
"bleak-esphome==3.3.0"
],

View File

@@ -4,7 +4,7 @@ from __future__ import annotations
import json
import logging
from typing import Any, cast
from typing import TYPE_CHECKING, Any, cast
from google.cloud import texttospeech
import voluptuous as vol
@@ -63,6 +63,7 @@ class GoogleCloudConfigFlow(ConfigFlow, domain=DOMAIN):
_name: str | None = None
entry: ConfigEntry | None = None
abort_reason: str | None = None
def _parse_uploaded_file(self, uploaded_file_id: str) -> dict[str, Any]:
"""Read and parse an uploaded JSON file."""
@@ -87,6 +88,8 @@ class GoogleCloudConfigFlow(ConfigFlow, domain=DOMAIN):
else:
data = {CONF_SERVICE_ACCOUNT_INFO: service_account_info}
if self.entry:
if TYPE_CHECKING:
assert self.abort_reason
return self.async_update_reload_and_abort(
self.entry, data=data, reason=self.abort_reason
)

View File

@@ -26,7 +26,6 @@ from homeassistant.config_entries import (
ConfigFlowResult,
FlowType,
OptionsFlow,
config_entry_progress_step,
)
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
@@ -73,6 +72,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Base flow to install firmware."""
ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override
_failed_addon_name: str
_failed_addon_reason: str
_picked_firmware_type: PickedFirmwareType
def __init__(self, *args: Any, **kwargs: Any) -> None:
@@ -84,6 +85,8 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
self._hardware_name: str = "unknown" # To be set in a subclass
self._zigbee_integration = ZigbeeIntegration.ZHA
self.addon_install_task: asyncio.Task | None = None
self.addon_start_task: asyncio.Task | None = None
self.addon_uninstall_task: asyncio.Task | None = None
self.firmware_install_task: asyncio.Task | None = None
self.installing_firmware_name: str | None = None
@@ -436,6 +439,18 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Install Zigbee firmware."""
raise NotImplementedError
async def async_step_addon_operation_failed(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Abort when add-on installation or start failed."""
return self.async_abort(
reason=self._failed_addon_reason,
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": self._failed_addon_name,
},
)
async def async_step_pre_confirm_zigbee(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
@@ -521,58 +536,79 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
"""Install Thread firmware."""
raise NotImplementedError
@config_entry_progress_step(
description_placeholders=lambda self: {
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
}
)
async def async_step_install_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> str:
) -> ConfigFlowResult:
"""Show progress dialog for installing the OTBR addon."""
addon_manager = get_otbr_addon_manager(self.hass)
addon_info = await self._async_get_addon_info(addon_manager)
_LOGGER.debug("OTBR addon info: %s", addon_info)
try:
await addon_manager.async_install_addon_waiting()
except AddonError as err:
_LOGGER.error(err)
raise AbortFlow(
"addon_install_failed",
if not self.addon_install_task:
self.addon_install_task = self.hass.async_create_task(
addon_manager.async_install_addon_waiting(),
"OTBR addon install",
)
if not self.addon_install_task.done():
return self.async_show_progress(
step_id="install_otbr_addon",
progress_action="install_addon",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": addon_manager.addon_name,
},
) from err
progress_task=self.addon_install_task,
)
return "install_thread_firmware"
@config_entry_progress_step(
description_placeholders=lambda self: {
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
}
)
async def async_step_start_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> str:
"""Configure OTBR to point to the SkyConnect and run the addon."""
try:
await self._configure_and_start_otbr_addon()
await self.addon_install_task
except AddonError as err:
_LOGGER.error(err)
raise AbortFlow(
"addon_start_failed",
self._failed_addon_name = addon_manager.addon_name
self._failed_addon_reason = "addon_install_failed"
return self.async_show_progress_done(next_step_id="addon_operation_failed")
finally:
self.addon_install_task = None
return self.async_show_progress_done(next_step_id="install_thread_firmware")
async def async_step_start_otbr_addon(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Configure OTBR to point to the SkyConnect and run the addon."""
otbr_manager = get_otbr_addon_manager(self.hass)
if not self.addon_start_task:
self.addon_start_task = self.hass.async_create_task(
self._configure_and_start_otbr_addon()
)
if not self.addon_start_task.done():
return self.async_show_progress(
step_id="start_otbr_addon",
progress_action="start_otbr_addon",
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": get_otbr_addon_manager(self.hass).addon_name,
"addon_name": otbr_manager.addon_name,
},
) from err
progress_task=self.addon_start_task,
)
return "pre_confirm_otbr"
try:
await self.addon_start_task
except (AddonError, AbortFlow) as err:
_LOGGER.error(err)
self._failed_addon_name = otbr_manager.addon_name
self._failed_addon_reason = (
err.reason if isinstance(err, AbortFlow) else "addon_start_failed"
)
return self.async_show_progress_done(next_step_id="addon_operation_failed")
finally:
self.addon_start_task = None
return self.async_show_progress_done(next_step_id="pre_confirm_otbr")
async def async_step_pre_confirm_otbr(
self, user_input: dict[str, Any] | None = None

View File

@@ -6,5 +6,5 @@
"iot_class": "local_push",
"loggers": ["nessclient"],
"quality_scale": "legacy",
"requirements": ["nessclient==1.2.0"]
"requirements": ["nessclient==1.3.1"]
}

View File

@@ -46,12 +46,7 @@ from .core import (
HomeAssistant,
callback,
)
from .data_entry_flow import (
FLOW_NOT_COMPLETE_STEPS,
FlowContext,
FlowResult,
progress_step,
)
from .data_entry_flow import FLOW_NOT_COMPLETE_STEPS, FlowContext, FlowResult
from .exceptions import (
ConfigEntryAuthFailed,
ConfigEntryError,
@@ -3875,22 +3870,6 @@ async def _async_get_flow_handler(
raise data_entry_flow.UnknownHandler
def config_entry_progress_step(
progress_action: str | None = None,
description_placeholders: dict[str, str]
| Callable[[Any], dict[str, str]]
| None = None,
) -> Callable[
[Callable[..., Coroutine[Any, Any, str | None]]],
Callable[..., Coroutine[Any, Any, ConfigFlowResult]],
]:
"""Decorator to create a progress step from an async function for config flows."""
return progress_step(
progress_action=progress_action,
description_placeholders=description_placeholders,
)
@callback
def _abort_reauth_flows(hass: HomeAssistant, domain: str, entry_id: str) -> None:
"""Abort reauth flows for an entry."""

View File

@@ -5,12 +5,11 @@ from __future__ import annotations
import abc
import asyncio
from collections import defaultdict
from collections.abc import Callable, Container, Coroutine, Hashable, Iterable, Mapping
from collections.abc import Callable, Container, Hashable, Iterable, Mapping
from contextlib import suppress
import copy
from dataclasses import dataclass
from enum import StrEnum
import functools
import logging
from types import MappingProxyType
from typing import Any, Generic, Required, TypedDict, TypeVar, cast
@@ -640,8 +639,6 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
__progress_task: asyncio.Task[Any] | None = None
__no_progress_task_reported = False
deprecated_show_progress = False
abort_reason: str = "abort"
abort_description_placeholders: Mapping[str, str] = MappingProxyType({})
@property
def source(self) -> str | None:
@@ -764,15 +761,6 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]):
description_placeholders=description_placeholders,
)
async def async_step_abort(
self, user_input: dict[str, Any] | None = None
) -> _FlowResultT:
"""Abort the flow."""
return self.async_abort(
reason=self.abort_reason,
description_placeholders=self.abort_description_placeholders,
)
@callback
def async_external_step(
self,
@@ -942,82 +930,3 @@ class section:
def __call__(self, value: Any) -> Any:
"""Validate input."""
return self.schema(value)
def progress_step(
progress_action: str | None = None,
description_placeholders: dict[str, str]
| Callable[[Any], dict[str, str]]
| None = None,
) -> Callable[
[Callable[..., Coroutine[Any, Any, str | None]]],
Callable[..., Coroutine[Any, Any, Any]],
]:
"""Decorator to create a progress step from an async function.
The decorated function should contain the actual work to be done.
It receives (self, user_input) and should return the next step id or raise AbortFlow
It can call self.async_update_progress(progress) to update progress.
Args:
progress_action: The progress action name for the UI. If None, inferred from method name.
description_placeholders: Static dict or callable that returns dict for progress UI placeholders.
"""
def decorator(
func: Callable[..., Coroutine[Any, Any, str | None]],
) -> Callable[..., Coroutine[Any, Any, Any]]:
@functools.wraps(func)
async def wrapper(
self: Any,
user_input: dict[str, Any] | None = None,
) -> Any:
step_id = func.__name__.replace("async_step_", "")
action = progress_action or step_id
# Initialize decorated progress tasks dict if it doesn't exist
if not hasattr(self, "_decorated_progress_tasks"):
self._decorated_progress_tasks = {}
# Check if we have a progress task running
progress_task = self._decorated_progress_tasks.get(step_id)
if progress_task is None:
# First call - create and start the progress task
progress_task = self.hass.async_create_task(
func(self, user_input), f"Progress step {step_id}"
)
self._decorated_progress_tasks[step_id] = progress_task
if not progress_task.done():
# Handle description placeholders
placeholders = None
if description_placeholders is not None:
if callable(description_placeholders):
placeholders = description_placeholders(self)
else:
placeholders = description_placeholders
return self.async_show_progress(
step_id=step_id,
progress_action=action,
progress_task=progress_task,
description_placeholders=placeholders,
)
# Task is done or this is a subsequent call
try:
next_step_id = await progress_task
except AbortFlow as err:
self.abort_reason = err.reason
self.abort_description_placeholders = err.description_placeholders or {}
return self.async_show_progress_done(next_step_id="abort")
finally:
# Clean up task reference
self._decorated_progress_tasks.pop(step_id, None)
return self.async_show_progress_done(next_step_id=next_step_id)
return wrapper
return decorator

View File

@@ -656,22 +656,29 @@ def _get_exposed_entities(
if not async_should_expose(hass, assistant, state.entity_id):
continue
description: str | None = None
entity_entry = entity_registry.async_get(state.entity_id)
names = [state.name]
device_name = None
area_names = []
if entity_entry is not None:
names.extend(entity_entry.aliases)
device = (
device_registry.async_get(entity_entry.device_id)
if entity_entry.device_id
else None
)
if device:
device_name = device.name_by_user or device.name
if entity_entry.area_id and (
area := area_registry.async_get_area(entity_entry.area_id)
):
# Entity is in area
area_names.append(area.name)
area_names.extend(area.aliases)
elif entity_entry.device_id and (
device := device_registry.async_get(entity_entry.device_id)
):
elif device:
# Check device area
if device.area_id and (
area := area_registry.async_get_area(device.area_id)
@@ -692,8 +699,8 @@ def _get_exposed_entities(
if (parsed_utc := dt_util.parse_datetime(state.state)) is not None:
info["state"] = dt_util.as_local(parsed_utc).isoformat()
if description:
info["description"] = description
if device_name:
info["device"] = device_name
if area_names:
info["areas"] = ", ".join(area_names)

View File

@@ -492,7 +492,7 @@ async def async_extract_config_entry_ids(
return config_entry_ids
def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE:
def _load_services_file(integration: Integration) -> JSON_TYPE:
"""Load services file for an integration."""
try:
return cast(
@@ -515,12 +515,10 @@ def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_T
return {}
def _load_services_files(
hass: HomeAssistant, integrations: Iterable[Integration]
) -> dict[str, JSON_TYPE]:
def _load_services_files(integrations: Iterable[Integration]) -> dict[str, JSON_TYPE]:
"""Load service files for multiple integrations."""
return {
integration.domain: _load_services_file(hass, integration)
integration.domain: _load_services_file(integration)
for integration in integrations
}
@@ -586,7 +584,7 @@ async def async_get_all_descriptions(
if integrations:
loaded = await hass.async_add_executor_job(
_load_services_files, hass, integrations
_load_services_files, integrations
)
# Load translations for all service domains

4
requirements_all.txt generated
View File

@@ -247,7 +247,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==41.4.0
aioesphomeapi==41.5.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -1503,7 +1503,7 @@ nad-receiver==0.3.0
ndms2-client==0.1.2
# homeassistant.components.ness_alarm
nessclient==1.2.0
nessclient==1.3.1
# homeassistant.components.netdata
netdata==1.3.0

View File

@@ -235,7 +235,7 @@ aioelectricitymaps==1.1.1
aioemonitor==1.0.5
# homeassistant.components.esphome
aioesphomeapi==41.4.0
aioesphomeapi==41.5.0
# homeassistant.components.flo
aioflo==2021.11.0
@@ -1292,7 +1292,7 @@ myuplink==0.7.0
ndms2-client==0.1.2
# homeassistant.components.ness_alarm
nessclient==1.2.0
nessclient==1.3.1
# homeassistant.components.nmap_tracker
netmap==0.7.0.2

View File

@@ -338,7 +338,7 @@ async def test_api_get_services(
assert data == snapshot
# Set up an integration with legacy translations in services.yaml
def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE:
def _load_services_file(integration: Integration) -> JSON_TYPE:
return {
"set_default_level": {
"description": "Translated description",

View File

@@ -714,7 +714,7 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None:
)
assert pick_result["type"] is FlowResultType.SHOW_PROGRESS
assert pick_result["progress_action"] == "install_otbr_addon"
assert pick_result["progress_action"] == "install_addon"
assert pick_result["step_id"] == "install_otbr_addon"
assert pick_result["description_placeholders"]["firmware_type"] == "ezsp"
assert pick_result["description_placeholders"]["model"] == TEST_HARDWARE_NAME
@@ -871,7 +871,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None:
)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["progress_action"] == "install_otbr_addon"
assert result["progress_action"] == "install_addon"
assert result["step_id"] == "install_otbr_addon"
await hass.async_block_till_done(wait_background_tasks=True)
@@ -1137,5 +1137,5 @@ async def test_config_flow_thread_migrate_handler(hass: HomeAssistant) -> None:
# Should proceed to OTBR addon installation (same as normal thread flow)
assert result["type"] is FlowResultType.SHOW_PROGRESS
assert result["progress_action"] == "install_otbr_addon"
assert result["progress_action"] == "install_addon"
assert result["step_id"] == "install_otbr_addon"

View File

@@ -178,7 +178,7 @@ async def test_cannot_probe_after_install_thread(hass: HomeAssistant) -> None:
)
assert pick_result["type"] is FlowResultType.SHOW_PROGRESS
assert pick_result["progress_action"] == "install_otbr_addon"
assert pick_result["progress_action"] == "install_addon"
assert pick_result["step_id"] == "install_otbr_addon"
description_placeholders = pick_result["description_placeholders"]
assert description_placeholders is not None

View File

@@ -718,7 +718,7 @@ async def test_get_services(
assert hass.data[ALL_SERVICE_DESCRIPTIONS_JSON_CACHE] is old_cache
# Set up an integration with legacy translations in services.yaml
def _load_services_file(hass: HomeAssistant, integration: Integration) -> JSON_TYPE:
def _load_services_file(integration: Integration) -> JSON_TYPE:
return {
"set_default_level": {
"description": "Translated description",

View File

@@ -453,6 +453,7 @@ async def test_assist_api_prompt(
connections={("test", "1234")},
suggested_area="Test Area",
)
device_registry.async_update_device(device.id, name_by_user="Friendly Device")
area = area_registry.async_get_area_by_name("Test Area")
area_registry.async_update(area.id, aliases=["Alternative name"])
entry1 = entity_registry.async_get_or_create(
@@ -580,6 +581,7 @@ async def test_assist_api_prompt(
- names: '1'
domain: light
state: unavailable
device: 1
areas: Test Area 2
- names: Kitchen
domain: light
@@ -590,34 +592,42 @@ async def test_assist_api_prompt(
- names: Living Room
domain: light
state: 'on'
device: Friendly Device
areas: Test Area, Alternative name
- names: Test Device, my test light
domain: light
state: unavailable
device: Friendly Device
areas: Test Area, Alternative name
- names: Test Device 2
domain: light
state: unavailable
device: Test Device 2
areas: Test Area 2
- names: Test Device 3
domain: light
state: unavailable
device: Test Device 3
areas: Test Area 2
- names: Test Device 4
domain: light
state: unavailable
device: Test Device 4
areas: Test Area 2
- names: Test Service
domain: light
state: unavailable
device: Test Service
areas: Test Area, Alternative name
- names: Test Service
domain: light
state: unavailable
device: Test Service
areas: Test Area, Alternative name
- names: Test Service
domain: light
state: unavailable
device: Test Service
areas: Test Area, Alternative name
- names: Unnamed Device
domain: light
@@ -627,32 +637,41 @@ async def test_assist_api_prompt(
stateless_exposed_entities_prompt = """Static Context: An overview of the areas and the devices in this smart home:
- names: '1'
domain: light
device: 1
areas: Test Area 2
- names: Kitchen
domain: light
- names: Living Room
domain: light
device: Friendly Device
areas: Test Area, Alternative name
- names: Test Device, my test light
domain: light
device: Friendly Device
areas: Test Area, Alternative name
- names: Test Device 2
domain: light
device: Test Device 2
areas: Test Area 2
- names: Test Device 3
domain: light
device: Test Device 3
areas: Test Area 2
- names: Test Device 4
domain: light
device: Test Device 4
areas: Test Area 2
- names: Test Service
domain: light
device: Test Service
areas: Test Area, Alternative name
- names: Test Service
domain: light
device: Test Service
areas: Test Area, Alternative name
- names: Test Service
domain: light
device: Test Service
areas: Test Area, Alternative name
- names: Unnamed Device
domain: light

View File

@@ -837,7 +837,7 @@ async def test_async_get_all_descriptions(hass: HomeAssistant) -> None:
# Test we only load services.yaml for integrations with services.yaml
# And system_health has no services
assert proxy_load_services_files.mock_calls[0][1][1] == unordered(
assert proxy_load_services_files.mock_calls[0][1][0] == unordered(
[
await async_get_integration(hass, DOMAIN_GROUP),
]
@@ -990,7 +990,7 @@ async def test_async_get_all_descriptions_dot_keys(hass: HomeAssistant) -> None:
descriptions = await service.async_get_all_descriptions(hass)
mock_load_yaml.assert_called_once_with("services.yaml", None)
assert proxy_load_services_files.mock_calls[0][1][1] == unordered(
assert proxy_load_services_files.mock_calls[0][1][0] == unordered(
[
await async_get_integration(hass, domain),
]
@@ -1085,7 +1085,7 @@ async def test_async_get_all_descriptions_filter(hass: HomeAssistant) -> None:
descriptions = await service.async_get_all_descriptions(hass)
mock_load_yaml.assert_called_once_with("services.yaml", None)
assert proxy_load_services_files.mock_calls[0][1][1] == unordered(
assert proxy_load_services_files.mock_calls[0][1][0] == unordered(
[
await async_get_integration(hass, domain),
]