This commit is contained in:
Franck Nijhof 2024-02-09 11:04:19 +01:00 committed by GitHub
commit cfd1f7809f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
38 changed files with 873 additions and 108 deletions

View File

@ -5,5 +5,5 @@
"config_flow": true, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/aosmith", "documentation": "https://www.home-assistant.io/integrations/aosmith",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"requirements": ["py-aosmith==1.0.6"] "requirements": ["py-aosmith==1.0.8"]
} }

View File

@ -1,4 +1,5 @@
"""Intents for the client integration.""" """Intents for the client integration."""
from __future__ import annotations from __future__ import annotations
import voluptuous as vol import voluptuous as vol
@ -36,24 +37,34 @@ class GetTemperatureIntent(intent.IntentHandler):
if not entities: if not entities:
raise intent.IntentHandleError("No climate entities") raise intent.IntentHandleError("No climate entities")
if "area" in slots: name_slot = slots.get("name", {})
# Filter by area entity_name: str | None = name_slot.get("value")
area_name = slots["area"]["value"] entity_text: str | None = name_slot.get("text")
area_slot = slots.get("area", {})
area_id = area_slot.get("value")
if area_id:
# Filter by area and optionally name
area_name = area_slot.get("text")
for maybe_climate in intent.async_match_states( for maybe_climate in intent.async_match_states(
hass, area_name=area_name, domains=[DOMAIN] hass, name=entity_name, area_name=area_id, domains=[DOMAIN]
): ):
climate_state = maybe_climate climate_state = maybe_climate
break break
if climate_state is None: if climate_state is None:
raise intent.IntentHandleError(f"No climate entity in area {area_name}") raise intent.NoStatesMatchedError(
name=entity_text or entity_name,
area=area_name or area_id,
domains={DOMAIN},
device_classes=None,
)
climate_entity = component.get_entity(climate_state.entity_id) climate_entity = component.get_entity(climate_state.entity_id)
elif "name" in slots: elif entity_name:
# Filter by name # Filter by name
entity_name = slots["name"]["value"]
for maybe_climate in intent.async_match_states( for maybe_climate in intent.async_match_states(
hass, name=entity_name, domains=[DOMAIN] hass, name=entity_name, domains=[DOMAIN]
): ):
@ -61,7 +72,12 @@ class GetTemperatureIntent(intent.IntentHandler):
break break
if climate_state is None: if climate_state is None:
raise intent.IntentHandleError(f"No climate entity named {entity_name}") raise intent.NoStatesMatchedError(
name=entity_name,
area=None,
domains={DOMAIN},
device_classes=None,
)
climate_entity = component.get_entity(climate_state.entity_id) climate_entity = component.get_entity(climate_state.entity_id)
else: else:

View File

@ -7,5 +7,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aioelectricitymaps"], "loggers": ["aioelectricitymaps"],
"requirements": ["aioelectricitymaps==0.3.1"] "requirements": ["aioelectricitymaps==0.4.0"]
} }

View File

@ -223,22 +223,22 @@ class DefaultAgent(AbstractConversationAgent):
# Check if a trigger matched # Check if a trigger matched
if isinstance(result, SentenceTriggerResult): if isinstance(result, SentenceTriggerResult):
# Gather callback responses in parallel # Gather callback responses in parallel
trigger_responses = await asyncio.gather( trigger_callbacks = [
*(
self._trigger_sentences[trigger_id].callback( self._trigger_sentences[trigger_id].callback(
result.sentence, trigger_result result.sentence, trigger_result
) )
for trigger_id, trigger_result in result.matched_triggers.items() for trigger_id, trigger_result in result.matched_triggers.items()
) ]
)
# Use last non-empty result as response. # Use last non-empty result as response.
# #
# There may be multiple copies of a trigger running when editing in # There may be multiple copies of a trigger running when editing in
# the UI, so it's critical that we filter out empty responses here. # the UI, so it's critical that we filter out empty responses here.
response_text: str | None = None response_text: str | None = None
for trigger_response in trigger_responses: for trigger_future in asyncio.as_completed(trigger_callbacks):
response_text = response_text or trigger_response if trigger_response := await trigger_future:
response_text = trigger_response
break
# Convert to conversation result # Convert to conversation result
response = intent.IntentResponse(language=language) response = intent.IntentResponse(language=language)
@ -316,6 +316,20 @@ class DefaultAgent(AbstractConversationAgent):
), ),
conversation_id, conversation_id,
) )
except intent.DuplicateNamesMatchedError as duplicate_names_error:
# Intent was valid, but two or more entities with the same name matched.
(
error_response_type,
error_response_args,
) = _get_duplicate_names_matched_response(duplicate_names_error)
return _make_error_result(
language,
intent.IntentResponseErrorCode.NO_VALID_TARGETS,
self._get_error_text(
error_response_type, lang_intents, **error_response_args
),
conversation_id,
)
except intent.IntentHandleError: except intent.IntentHandleError:
# Intent was valid and entities matched constraints, but an error # Intent was valid and entities matched constraints, but an error
# occurred during handling. # occurred during handling.
@ -724,7 +738,12 @@ class DefaultAgent(AbstractConversationAgent):
if async_should_expose(self.hass, DOMAIN, state.entity_id) if async_should_expose(self.hass, DOMAIN, state.entity_id)
] ]
# Gather exposed entity names # Gather exposed entity names.
#
# NOTE: We do not pass entity ids in here because multiple entities may
# have the same name. The intent matcher doesn't gather all matching
# values for a list, just the first. So we will need to match by name no
# matter what.
entity_names = [] entity_names = []
for state in states: for state in states:
# Checked against "requires_context" and "excludes_context" in hassil # Checked against "requires_context" and "excludes_context" in hassil
@ -740,7 +759,7 @@ class DefaultAgent(AbstractConversationAgent):
if not entity: if not entity:
# Default name # Default name
entity_names.append((state.name, state.entity_id, context)) entity_names.append((state.name, state.name, context))
continue continue
if entity.aliases: if entity.aliases:
@ -748,12 +767,15 @@ class DefaultAgent(AbstractConversationAgent):
if not alias.strip(): if not alias.strip():
continue continue
entity_names.append((alias, state.entity_id, context)) entity_names.append((alias, alias, context))
# Default name # Default name
entity_names.append((state.name, state.entity_id, context)) entity_names.append((state.name, state.name, context))
# Expose all areas # Expose all areas.
#
# We pass in area id here with the expectation that no two areas will
# share the same name or alias.
areas = ar.async_get(self.hass) areas = ar.async_get(self.hass)
area_names = [] area_names = []
for area in areas.async_list_areas(): for area in areas.async_list_areas():
@ -984,6 +1006,20 @@ def _get_no_states_matched_response(
return ErrorKey.NO_INTENT, {} return ErrorKey.NO_INTENT, {}
def _get_duplicate_names_matched_response(
duplicate_names_error: intent.DuplicateNamesMatchedError,
) -> tuple[ErrorKey, dict[str, Any]]:
"""Return key and template arguments for error when intent returns duplicate matches."""
if duplicate_names_error.area:
return ErrorKey.DUPLICATE_ENTITIES_IN_AREA, {
"entity": duplicate_names_error.name,
"area": duplicate_names_error.area,
}
return ErrorKey.DUPLICATE_ENTITIES, {"entity": duplicate_names_error.name}
def _collect_list_references(expression: Expression, list_names: set[str]) -> None: def _collect_list_references(expression: Expression, list_names: set[str]) -> None:
"""Collect list reference names recursively.""" """Collect list reference names recursively."""
if isinstance(expression, Sequence): if isinstance(expression, Sequence):

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs", "documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"], "loggers": ["sleekxmppfs", "sucks", "deebot_client"],
"requirements": ["py-sucks==0.9.8", "deebot-client==5.1.0"] "requirements": ["py-sucks==0.9.8", "deebot-client==5.1.1"]
} }

View File

@ -6,5 +6,5 @@
"dependencies": ["webhook"], "dependencies": ["webhook"],
"documentation": "https://www.home-assistant.io/integrations/ecowitt", "documentation": "https://www.home-assistant.io/integrations/ecowitt",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["aioecowitt==2024.2.0"] "requirements": ["aioecowitt==2024.2.1"]
} }

View File

@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/evohome", "documentation": "https://www.home-assistant.io/integrations/evohome",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["evohomeasync", "evohomeasync2"], "loggers": ["evohomeasync", "evohomeasync2"],
"requirements": ["evohome-async==0.4.17"] "requirements": ["evohome-async==0.4.18"]
} }

View File

@ -20,5 +20,5 @@
"documentation": "https://www.home-assistant.io/integrations/frontend", "documentation": "https://www.home-assistant.io/integrations/frontend",
"integration_type": "system", "integration_type": "system",
"quality_scale": "internal", "quality_scale": "internal",
"requirements": ["home-assistant-frontend==20240207.0"] "requirements": ["home-assistant-frontend==20240207.1"]
} }

View File

@ -7,5 +7,5 @@
"integration_type": "service", "integration_type": "service",
"iot_class": "cloud_polling", "iot_class": "cloud_polling",
"loggers": ["aio_geojson_geonetnz_volcano"], "loggers": ["aio_geojson_geonetnz_volcano"],
"requirements": ["aio-geojson-geonetnz-volcano==0.8"] "requirements": ["aio-geojson-geonetnz-volcano==0.9"]
} }

View File

@ -506,7 +506,6 @@ class HassIO:
options = { options = {
"ssl": CONF_SSL_CERTIFICATE in http_config, "ssl": CONF_SSL_CERTIFICATE in http_config,
"port": port, "port": port,
"watchdog": True,
"refresh_token": refresh_token.token, "refresh_token": refresh_token.token,
} }

View File

@ -7,6 +7,7 @@ from typing import Any
from aiohttp import ClientConnectionError from aiohttp import ClientConnectionError
from aiosomecomfort import ( from aiosomecomfort import (
APIRateLimited,
AuthError, AuthError,
ConnectionError as AscConnectionError, ConnectionError as AscConnectionError,
SomeComfortError, SomeComfortError,
@ -505,10 +506,11 @@ class HoneywellUSThermostat(ClimateEntity):
await self._device.refresh() await self._device.refresh()
except ( except (
asyncio.TimeoutError,
AscConnectionError,
APIRateLimited,
AuthError, AuthError,
ClientConnectionError, ClientConnectionError,
AscConnectionError,
asyncio.TimeoutError,
): ):
self._retry += 1 self._retry += 1
self._attr_available = self._retry <= RETRY self._attr_available = self._retry <= RETRY
@ -524,7 +526,12 @@ class HoneywellUSThermostat(ClimateEntity):
await _login() await _login()
return return
except (AscConnectionError, ClientConnectionError, asyncio.TimeoutError): except (
asyncio.TimeoutError,
AscConnectionError,
APIRateLimited,
ClientConnectionError,
):
self._retry += 1 self._retry += 1
self._attr_available = self._retry <= RETRY self._attr_available = self._retry <= RETRY
return return

View File

@ -1,4 +1,5 @@
"""The Intent integration.""" """The Intent integration."""
from __future__ import annotations from __future__ import annotations
import logging import logging
@ -155,16 +156,18 @@ class GetStateIntentHandler(intent.IntentHandler):
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
# Entity name to match # Entity name to match
name: str | None = slots.get("name", {}).get("value") name_slot = slots.get("name", {})
entity_name: str | None = name_slot.get("value")
entity_text: str | None = name_slot.get("text")
# Look up area first to fail early # Look up area first to fail early
area_name = slots.get("area", {}).get("value") area_slot = slots.get("area", {})
area_id = area_slot.get("value")
area_name = area_slot.get("text")
area: ar.AreaEntry | None = None area: ar.AreaEntry | None = None
if area_name is not None: if area_id is not None:
areas = ar.async_get(hass) areas = ar.async_get(hass)
area = areas.async_get_area(area_name) or areas.async_get_area_by_name( area = areas.async_get_area(area_id)
area_name
)
if area is None: if area is None:
raise intent.IntentHandleError(f"No area named {area_name}") raise intent.IntentHandleError(f"No area named {area_name}")
@ -186,7 +189,7 @@ class GetStateIntentHandler(intent.IntentHandler):
states = list( states = list(
intent.async_match_states( intent.async_match_states(
hass, hass,
name=name, name=entity_name,
area=area, area=area,
domains=domains, domains=domains,
device_classes=device_classes, device_classes=device_classes,
@ -197,13 +200,20 @@ class GetStateIntentHandler(intent.IntentHandler):
_LOGGER.debug( _LOGGER.debug(
"Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s, assistant=%s", "Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s, assistant=%s",
len(states), len(states),
name, entity_name,
area, area,
domains, domains,
device_classes, device_classes,
intent_obj.assistant, intent_obj.assistant,
) )
if entity_name and (len(states) > 1):
# Multiple entities matched for the same name
raise intent.DuplicateNamesMatchedError(
name=entity_text or entity_name,
area=area_name or area_id,
)
# Create response # Create response
response = intent_obj.create_response() response = intent_obj.create_response()
response.response_type = intent.IntentResponseType.QUERY_ANSWER response.response_type = intent.IntentResponseType.QUERY_ANSWER

View File

@ -16,5 +16,5 @@
"integration_type": "hub", "integration_type": "hub",
"iot_class": "assumed_state", "iot_class": "assumed_state",
"loggers": ["keymitt_ble"], "loggers": ["keymitt_ble"],
"requirements": ["PyMicroBot==0.0.10"] "requirements": ["PyMicroBot==0.0.12"]
} }

View File

@ -52,11 +52,27 @@ class MatterAdapter:
async def setup_nodes(self) -> None: async def setup_nodes(self) -> None:
"""Set up all existing nodes and subscribe to new nodes.""" """Set up all existing nodes and subscribe to new nodes."""
initialized_nodes: set[int] = set()
for node in self.matter_client.get_nodes(): for node in self.matter_client.get_nodes():
if not node.available:
# ignore un-initialized nodes at startup
# catch them later when they become available.
continue
initialized_nodes.add(node.node_id)
self._setup_node(node) self._setup_node(node)
def node_added_callback(event: EventType, node: MatterNode) -> None: def node_added_callback(event: EventType, node: MatterNode) -> None:
"""Handle node added event.""" """Handle node added event."""
initialized_nodes.add(node.node_id)
self._setup_node(node)
def node_updated_callback(event: EventType, node: MatterNode) -> None:
"""Handle node updated event."""
if node.node_id in initialized_nodes:
return
if not node.available:
return
initialized_nodes.add(node.node_id)
self._setup_node(node) self._setup_node(node)
def endpoint_added_callback(event: EventType, data: dict[str, int]) -> None: def endpoint_added_callback(event: EventType, data: dict[str, int]) -> None:
@ -116,6 +132,11 @@ class MatterAdapter:
callback=node_added_callback, event_filter=EventType.NODE_ADDED callback=node_added_callback, event_filter=EventType.NODE_ADDED
) )
) )
self.config_entry.async_on_unload(
self.matter_client.subscribe_events(
callback=node_updated_callback, event_filter=EventType.NODE_UPDATED
)
)
def _setup_node(self, node: MatterNode) -> None: def _setup_node(self, node: MatterNode) -> None:
"""Set up an node.""" """Set up an node."""

View File

@ -129,6 +129,9 @@ class MatterEntity(Entity):
async def async_update(self) -> None: async def async_update(self) -> None:
"""Call when the entity needs to be updated.""" """Call when the entity needs to be updated."""
if not self._endpoint.node.available:
# skip poll when the node is not (yet) available
return
# manually poll/refresh the primary value # manually poll/refresh the primary value
await self.matter_client.refresh_attribute( await self.matter_client.refresh_attribute(
self._endpoint.node.node_id, self._endpoint.node.node_id,

View File

@ -6,5 +6,5 @@
"dependencies": ["websocket_api"], "dependencies": ["websocket_api"],
"documentation": "https://www.home-assistant.io/integrations/matter", "documentation": "https://www.home-assistant.io/integrations/matter",
"iot_class": "local_push", "iot_class": "local_push",
"requirements": ["python-matter-server==5.4.1"] "requirements": ["python-matter-server==5.5.0"]
} }

View File

@ -186,7 +186,7 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend(
] ]
), ),
vol.Optional(CONF_STRUCTURE): cv.string, vol.Optional(CONF_STRUCTURE): cv.string,
vol.Optional(CONF_SCALE, default=1): cv.positive_float, vol.Optional(CONF_SCALE, default=1): vol.Coerce(float),
vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float), vol.Optional(CONF_OFFSET, default=0): vol.Coerce(float),
vol.Optional(CONF_PRECISION): cv.positive_int, vol.Optional(CONF_PRECISION): cv.positive_int,
vol.Optional( vol.Optional(
@ -241,8 +241,8 @@ CLIMATE_SCHEMA = vol.All(
{ {
vol.Required(CONF_TARGET_TEMP): cv.positive_int, vol.Required(CONF_TARGET_TEMP): cv.positive_int,
vol.Optional(CONF_TARGET_TEMP_WRITE_REGISTERS, default=False): cv.boolean, vol.Optional(CONF_TARGET_TEMP_WRITE_REGISTERS, default=False): cv.boolean,
vol.Optional(CONF_MAX_TEMP, default=35): cv.positive_float, vol.Optional(CONF_MAX_TEMP, default=35): vol.Coerce(float),
vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_float, vol.Optional(CONF_MIN_TEMP, default=5): vol.Coerce(float),
vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float), vol.Optional(CONF_STEP, default=0.5): vol.Coerce(float),
vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string, vol.Optional(CONF_TEMPERATURE_UNIT, default=DEFAULT_TEMP_UNIT): cv.string,
vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int, vol.Optional(CONF_HVAC_ONOFF_REGISTER): cv.positive_int,
@ -342,8 +342,8 @@ SENSOR_SCHEMA = vol.All(
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string, vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
vol.Exclusive(CONF_VIRTUAL_COUNT, "vir_sen_count"): cv.positive_int, vol.Exclusive(CONF_VIRTUAL_COUNT, "vir_sen_count"): cv.positive_int,
vol.Exclusive(CONF_SLAVE_COUNT, "vir_sen_count"): cv.positive_int, vol.Exclusive(CONF_SLAVE_COUNT, "vir_sen_count"): cv.positive_int,
vol.Optional(CONF_MIN_VALUE): cv.positive_float, vol.Optional(CONF_MIN_VALUE): vol.Coerce(float),
vol.Optional(CONF_MAX_VALUE): cv.positive_float, vol.Optional(CONF_MAX_VALUE): vol.Coerce(float),
vol.Optional(CONF_NAN_VALUE): nan_validator, vol.Optional(CONF_NAN_VALUE): nan_validator,
vol.Optional(CONF_ZERO_SUPPRESS): cv.positive_float, vol.Optional(CONF_ZERO_SUPPRESS): cv.positive_float,
} }

View File

@ -364,7 +364,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
# Translate the value received # Translate the value received
if fan_mode is not None: if fan_mode is not None:
self._attr_fan_mode = self._fan_mode_mapping_from_modbus[int(fan_mode)] self._attr_fan_mode = self._fan_mode_mapping_from_modbus.get(
int(fan_mode), self._attr_fan_mode
)
# Read the on/off register if defined. If the value in this # Read the on/off register if defined. If the value in this
# register is "OFF", it will take precedence over the value # register is "OFF", it will take precedence over the value

View File

@ -7,6 +7,7 @@ from collections.abc import (
Callable, Callable,
Coroutine, Coroutine,
Generator, Generator,
Hashable,
Iterable, Iterable,
Mapping, Mapping,
ValuesView, ValuesView,
@ -49,6 +50,7 @@ from .helpers.event import (
) )
from .helpers.frame import report from .helpers.frame import report
from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType from .helpers.typing import UNDEFINED, ConfigType, DiscoveryInfoType, UndefinedType
from .loader import async_suggest_report_issue
from .setup import DATA_SETUP_DONE, async_process_deps_reqs, async_setup_component from .setup import DATA_SETUP_DONE, async_process_deps_reqs, async_setup_component
from .util import uuid as uuid_util from .util import uuid as uuid_util
from .util.decorator import Registry from .util.decorator import Registry
@ -1124,9 +1126,10 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
- domain -> unique_id -> ConfigEntry - domain -> unique_id -> ConfigEntry
""" """
def __init__(self) -> None: def __init__(self, hass: HomeAssistant) -> None:
"""Initialize the container.""" """Initialize the container."""
super().__init__() super().__init__()
self._hass = hass
self._domain_index: dict[str, list[ConfigEntry]] = {} self._domain_index: dict[str, list[ConfigEntry]] = {}
self._domain_unique_id_index: dict[str, dict[str, ConfigEntry]] = {} self._domain_unique_id_index: dict[str, dict[str, ConfigEntry]] = {}
@ -1145,8 +1148,27 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
data[entry_id] = entry data[entry_id] = entry
self._domain_index.setdefault(entry.domain, []).append(entry) self._domain_index.setdefault(entry.domain, []).append(entry)
if entry.unique_id is not None: if entry.unique_id is not None:
unique_id_hash = entry.unique_id
# Guard against integrations using unhashable unique_id
# In HA Core 2024.9, we should remove the guard and instead fail
if not isinstance(entry.unique_id, Hashable):
unique_id_hash = str(entry.unique_id) # type: ignore[unreachable]
report_issue = async_suggest_report_issue(
self._hass, integration_domain=entry.domain
)
_LOGGER.error(
(
"Config entry '%s' from integration %s has an invalid unique_id"
" '%s', please %s"
),
entry.title,
entry.domain,
entry.unique_id,
report_issue,
)
self._domain_unique_id_index.setdefault(entry.domain, {})[ self._domain_unique_id_index.setdefault(entry.domain, {})[
entry.unique_id unique_id_hash
] = entry ] = entry
def _unindex_entry(self, entry_id: str) -> None: def _unindex_entry(self, entry_id: str) -> None:
@ -1157,6 +1179,9 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
if not self._domain_index[domain]: if not self._domain_index[domain]:
del self._domain_index[domain] del self._domain_index[domain]
if (unique_id := entry.unique_id) is not None: if (unique_id := entry.unique_id) is not None:
# Check type first to avoid expensive isinstance call
if type(unique_id) is not str and not isinstance(unique_id, Hashable): # noqa: E721
unique_id = str(entry.unique_id) # type: ignore[unreachable]
del self._domain_unique_id_index[domain][unique_id] del self._domain_unique_id_index[domain][unique_id]
if not self._domain_unique_id_index[domain]: if not self._domain_unique_id_index[domain]:
del self._domain_unique_id_index[domain] del self._domain_unique_id_index[domain]
@ -1174,6 +1199,9 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
self, domain: str, unique_id: str self, domain: str, unique_id: str
) -> ConfigEntry | None: ) -> ConfigEntry | None:
"""Get entry by domain and unique id.""" """Get entry by domain and unique id."""
# Check type first to avoid expensive isinstance call
if type(unique_id) is not str and not isinstance(unique_id, Hashable): # noqa: E721
unique_id = str(unique_id) # type: ignore[unreachable]
return self._domain_unique_id_index.get(domain, {}).get(unique_id) return self._domain_unique_id_index.get(domain, {}).get(unique_id)
@ -1189,7 +1217,7 @@ class ConfigEntries:
self.flow = ConfigEntriesFlowManager(hass, self, hass_config) self.flow = ConfigEntriesFlowManager(hass, self, hass_config)
self.options = OptionsFlowManager(hass) self.options = OptionsFlowManager(hass)
self._hass_config = hass_config self._hass_config = hass_config
self._entries = ConfigEntryItems() self._entries = ConfigEntryItems(hass)
self._store = storage.Store[dict[str, list[dict[str, Any]]]]( self._store = storage.Store[dict[str, list[dict[str, Any]]]](
hass, STORAGE_VERSION, STORAGE_KEY hass, STORAGE_VERSION, STORAGE_KEY
) )
@ -1314,10 +1342,10 @@ class ConfigEntries:
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown) self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown)
if config is None: if config is None:
self._entries = ConfigEntryItems() self._entries = ConfigEntryItems(self.hass)
return return
entries: ConfigEntryItems = ConfigEntryItems() entries: ConfigEntryItems = ConfigEntryItems(self.hass)
for entry in config["entries"]: for entry in config["entries"]:
pref_disable_new_entities = entry.get("pref_disable_new_entities") pref_disable_new_entities = entry.get("pref_disable_new_entities")

View File

@ -16,7 +16,7 @@ from .helpers.deprecation import (
APPLICATION_NAME: Final = "HomeAssistant" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024 MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 2 MINOR_VERSION: Final = 2
PATCH_VERSION: Final = "0" PATCH_VERSION: Final = "1"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0)

View File

@ -155,6 +155,17 @@ class NoStatesMatchedError(IntentError):
self.device_classes = device_classes self.device_classes = device_classes
class DuplicateNamesMatchedError(IntentError):
"""Error when two or more entities with the same name matched."""
def __init__(self, name: str, area: str | None) -> None:
"""Initialize error."""
super().__init__()
self.name = name
self.area = area
def _is_device_class( def _is_device_class(
state: State, state: State,
entity: entity_registry.RegistryEntry | None, entity: entity_registry.RegistryEntry | None,
@ -318,8 +329,6 @@ def async_match_states(
for state, entity in states_and_entities: for state, entity in states_and_entities:
if _has_name(state, entity, name): if _has_name(state, entity, name):
yield state yield state
break
else: else:
# Not filtered by name # Not filtered by name
for state, _entity in states_and_entities: for state, _entity in states_and_entities:
@ -403,11 +412,11 @@ class ServiceIntentHandler(IntentHandler):
slots = self.async_validate_slots(intent_obj.slots) slots = self.async_validate_slots(intent_obj.slots)
name_slot = slots.get("name", {}) name_slot = slots.get("name", {})
entity_id: str | None = name_slot.get("value") entity_name: str | None = name_slot.get("value")
entity_name: str | None = name_slot.get("text") entity_text: str | None = name_slot.get("text")
if entity_id == "all": if entity_name == "all":
# Don't match on name if targeting all entities # Don't match on name if targeting all entities
entity_id = None entity_name = None
# Look up area first to fail early # Look up area first to fail early
area_slot = slots.get("area", {}) area_slot = slots.get("area", {})
@ -416,9 +425,7 @@ class ServiceIntentHandler(IntentHandler):
area: area_registry.AreaEntry | None = None area: area_registry.AreaEntry | None = None
if area_id is not None: if area_id is not None:
areas = area_registry.async_get(hass) areas = area_registry.async_get(hass)
area = areas.async_get_area(area_id) or areas.async_get_area_by_name( area = areas.async_get_area(area_id)
area_name
)
if area is None: if area is None:
raise IntentHandleError(f"No area named {area_name}") raise IntentHandleError(f"No area named {area_name}")
@ -436,7 +443,7 @@ class ServiceIntentHandler(IntentHandler):
states = list( states = list(
async_match_states( async_match_states(
hass, hass,
name=entity_id, name=entity_name,
area=area, area=area,
domains=domains, domains=domains,
device_classes=device_classes, device_classes=device_classes,
@ -447,14 +454,24 @@ class ServiceIntentHandler(IntentHandler):
if not states: if not states:
# No states matched constraints # No states matched constraints
raise NoStatesMatchedError( raise NoStatesMatchedError(
name=entity_name or entity_id, name=entity_text or entity_name,
area=area_name or area_id, area=area_name or area_id,
domains=domains, domains=domains,
device_classes=device_classes, device_classes=device_classes,
) )
if entity_name and (len(states) > 1):
# Multiple entities matched for the same name
raise DuplicateNamesMatchedError(
name=entity_text or entity_name,
area=area_name or area_id,
)
response = await self.async_handle_states(intent_obj, states, area) response = await self.async_handle_states(intent_obj, states, area)
# Make the matched states available in the response
response.async_set_states(matched_states=states, unmatched_states=[])
return response return response
async def async_handle_states( async def async_handle_states(

View File

@ -273,7 +273,13 @@ class _TranslationCache:
for key, value in updated_resources.items(): for key, value in updated_resources.items():
if key not in cached_resources: if key not in cached_resources:
continue continue
try:
tuples = list(string.Formatter().parse(value)) tuples = list(string.Formatter().parse(value))
except ValueError:
_LOGGER.error(
("Error while parsing localized (%s) string %s"), language, key
)
continue
updated_placeholders = {tup[1] for tup in tuples if tup[1] is not None} updated_placeholders = {tup[1] for tup in tuples if tup[1] is not None}
tuples = list(string.Formatter().parse(cached_resources[key])) tuples = list(string.Formatter().parse(cached_resources[key]))

View File

@ -28,7 +28,7 @@ habluetooth==2.4.0
hass-nabucasa==0.76.0 hass-nabucasa==0.76.0
hassil==1.6.1 hassil==1.6.1
home-assistant-bluetooth==1.12.0 home-assistant-bluetooth==1.12.0
home-assistant-frontend==20240207.0 home-assistant-frontend==20240207.1
home-assistant-intents==2024.2.2 home-assistant-intents==2024.2.2
httpx==0.26.0 httpx==0.26.0
ifaddr==0.2.0 ifaddr==0.2.0

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "homeassistant" name = "homeassistant"
version = "2024.2.0" version = "2024.2.1"
license = {text = "Apache-2.0"} license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3." description = "Open-source home automation platform running on Python 3."
readme = "README.rst" readme = "README.rst"

View File

@ -76,7 +76,7 @@ PyMetEireann==2021.8.0
PyMetno==0.11.0 PyMetno==0.11.0
# homeassistant.components.keymitt_ble # homeassistant.components.keymitt_ble
PyMicroBot==0.0.10 PyMicroBot==0.0.12
# homeassistant.components.nina # homeassistant.components.nina
PyNINA==0.3.3 PyNINA==0.3.3
@ -173,7 +173,7 @@ aio-geojson-generic-client==0.4
aio-geojson-geonetnz-quakes==0.16 aio-geojson-geonetnz-quakes==0.16
# homeassistant.components.geonetnz_volcano # homeassistant.components.geonetnz_volcano
aio-geojson-geonetnz-volcano==0.8 aio-geojson-geonetnz-volcano==0.9
# homeassistant.components.nsw_rural_fire_service_feed # homeassistant.components.nsw_rural_fire_service_feed
aio-geojson-nsw-rfs-incidents==0.7 aio-geojson-nsw-rfs-incidents==0.7
@ -230,10 +230,10 @@ aioeafm==0.1.2
aioeagle==1.1.0 aioeagle==1.1.0
# homeassistant.components.ecowitt # homeassistant.components.ecowitt
aioecowitt==2024.2.0 aioecowitt==2024.2.1
# homeassistant.components.co2signal # homeassistant.components.co2signal
aioelectricitymaps==0.3.1 aioelectricitymaps==0.4.0
# homeassistant.components.emonitor # homeassistant.components.emonitor
aioemonitor==1.0.5 aioemonitor==1.0.5
@ -684,7 +684,7 @@ debugpy==1.8.0
# decora==0.6 # decora==0.6
# homeassistant.components.ecovacs # homeassistant.components.ecovacs
deebot-client==5.1.0 deebot-client==5.1.1
# homeassistant.components.ihc # homeassistant.components.ihc
# homeassistant.components.namecheapdns # homeassistant.components.namecheapdns
@ -818,7 +818,7 @@ eufylife-ble-client==0.1.8
# evdev==1.6.1 # evdev==1.6.1
# homeassistant.components.evohome # homeassistant.components.evohome
evohome-async==0.4.17 evohome-async==0.4.18
# homeassistant.components.faa_delays # homeassistant.components.faa_delays
faadelays==2023.9.1 faadelays==2023.9.1
@ -1059,7 +1059,7 @@ hole==0.8.0
holidays==0.42 holidays==0.42
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20240207.0 home-assistant-frontend==20240207.1
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2024.2.2 home-assistant-intents==2024.2.2
@ -1579,7 +1579,7 @@ pushover_complete==1.1.1
pvo==2.1.1 pvo==2.1.1
# homeassistant.components.aosmith # homeassistant.components.aosmith
py-aosmith==1.0.6 py-aosmith==1.0.8
# homeassistant.components.canary # homeassistant.components.canary
py-canary==0.5.3 py-canary==0.5.3
@ -2238,7 +2238,7 @@ python-kasa[speedups]==0.6.2.1
# python-lirc==1.2.3 # python-lirc==1.2.3
# homeassistant.components.matter # homeassistant.components.matter
python-matter-server==5.4.1 python-matter-server==5.5.0
# homeassistant.components.xiaomi_miio # homeassistant.components.xiaomi_miio
python-miio==0.5.12 python-miio==0.5.12

View File

@ -64,7 +64,7 @@ PyMetEireann==2021.8.0
PyMetno==0.11.0 PyMetno==0.11.0
# homeassistant.components.keymitt_ble # homeassistant.components.keymitt_ble
PyMicroBot==0.0.10 PyMicroBot==0.0.12
# homeassistant.components.nina # homeassistant.components.nina
PyNINA==0.3.3 PyNINA==0.3.3
@ -152,7 +152,7 @@ aio-geojson-generic-client==0.4
aio-geojson-geonetnz-quakes==0.16 aio-geojson-geonetnz-quakes==0.16
# homeassistant.components.geonetnz_volcano # homeassistant.components.geonetnz_volcano
aio-geojson-geonetnz-volcano==0.8 aio-geojson-geonetnz-volcano==0.9
# homeassistant.components.nsw_rural_fire_service_feed # homeassistant.components.nsw_rural_fire_service_feed
aio-geojson-nsw-rfs-incidents==0.7 aio-geojson-nsw-rfs-incidents==0.7
@ -209,10 +209,10 @@ aioeafm==0.1.2
aioeagle==1.1.0 aioeagle==1.1.0
# homeassistant.components.ecowitt # homeassistant.components.ecowitt
aioecowitt==2024.2.0 aioecowitt==2024.2.1
# homeassistant.components.co2signal # homeassistant.components.co2signal
aioelectricitymaps==0.3.1 aioelectricitymaps==0.4.0
# homeassistant.components.emonitor # homeassistant.components.emonitor
aioemonitor==1.0.5 aioemonitor==1.0.5
@ -559,7 +559,7 @@ dbus-fast==2.21.1
debugpy==1.8.0 debugpy==1.8.0
# homeassistant.components.ecovacs # homeassistant.components.ecovacs
deebot-client==5.1.0 deebot-client==5.1.1
# homeassistant.components.ihc # homeassistant.components.ihc
# homeassistant.components.namecheapdns # homeassistant.components.namecheapdns
@ -855,7 +855,7 @@ hole==0.8.0
holidays==0.42 holidays==0.42
# homeassistant.components.frontend # homeassistant.components.frontend
home-assistant-frontend==20240207.0 home-assistant-frontend==20240207.1
# homeassistant.components.conversation # homeassistant.components.conversation
home-assistant-intents==2024.2.2 home-assistant-intents==2024.2.2
@ -1232,7 +1232,7 @@ pushover_complete==1.1.1
pvo==2.1.1 pvo==2.1.1
# homeassistant.components.aosmith # homeassistant.components.aosmith
py-aosmith==1.0.6 py-aosmith==1.0.8
# homeassistant.components.canary # homeassistant.components.canary
py-canary==0.5.3 py-canary==0.5.3
@ -1711,7 +1711,7 @@ python-juicenet==1.1.0
python-kasa[speedups]==0.6.2.1 python-kasa[speedups]==0.6.2.1
# homeassistant.components.matter # homeassistant.components.matter
python-matter-server==5.4.1 python-matter-server==5.5.0
# homeassistant.components.xiaomi_miio # homeassistant.components.xiaomi_miio
python-miio==0.5.12 python-miio==0.5.12

View File

@ -1,4 +1,5 @@
"""Test climate intents.""" """Test climate intents."""
from collections.abc import Generator from collections.abc import Generator
from unittest.mock import patch from unittest.mock import patch
@ -135,8 +136,10 @@ async def test_get_temperature(
# Add climate entities to different areas: # Add climate entities to different areas:
# climate_1 => living room # climate_1 => living room
# climate_2 => bedroom # climate_2 => bedroom
# nothing in office
living_room_area = area_registry.async_create(name="Living Room") living_room_area = area_registry.async_create(name="Living Room")
bedroom_area = area_registry.async_create(name="Bedroom") bedroom_area = area_registry.async_create(name="Bedroom")
office_area = area_registry.async_create(name="Office")
entity_registry.async_update_entity( entity_registry.async_update_entity(
climate_1.entity_id, area_id=living_room_area.id climate_1.entity_id, area_id=living_room_area.id
@ -158,7 +161,7 @@ async def test_get_temperature(
hass, hass,
"test", "test",
climate_intent.INTENT_GET_TEMPERATURE, climate_intent.INTENT_GET_TEMPERATURE,
{"area": {"value": "Bedroom"}}, {"area": {"value": bedroom_area.name}},
) )
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
assert len(response.matched_states) == 1 assert len(response.matched_states) == 1
@ -179,6 +182,52 @@ async def test_get_temperature(
state = response.matched_states[0] state = response.matched_states[0]
assert state.attributes["current_temperature"] == 22.0 assert state.attributes["current_temperature"] == 22.0
# Check area with no climate entities
with pytest.raises(intent.NoStatesMatchedError) as error:
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"area": {"value": office_area.name}},
)
# Exception should contain details of what we tried to match
assert isinstance(error.value, intent.NoStatesMatchedError)
assert error.value.name is None
assert error.value.area == office_area.name
assert error.value.domains == {DOMAIN}
assert error.value.device_classes is None
# Check wrong name
with pytest.raises(intent.NoStatesMatchedError) as error:
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"name": {"value": "Does not exist"}},
)
assert isinstance(error.value, intent.NoStatesMatchedError)
assert error.value.name == "Does not exist"
assert error.value.area is None
assert error.value.domains == {DOMAIN}
assert error.value.device_classes is None
# Check wrong name with area
with pytest.raises(intent.NoStatesMatchedError) as error:
response = await intent.async_handle(
hass,
"test",
climate_intent.INTENT_GET_TEMPERATURE,
{"name": {"value": "Climate 1"}, "area": {"value": bedroom_area.name}},
)
assert isinstance(error.value, intent.NoStatesMatchedError)
assert error.value.name == "Climate 1"
assert error.value.area == bedroom_area.name
assert error.value.domains == {DOMAIN}
assert error.value.device_classes is None
async def test_get_temperature_no_entities( async def test_get_temperature_no_entities(
hass: HomeAssistant, hass: HomeAssistant,
@ -216,19 +265,28 @@ async def test_get_temperature_no_state(
climate_1.entity_id, area_id=living_room_area.id climate_1.entity_id, area_id=living_room_area.id
) )
with patch("homeassistant.core.StateMachine.get", return_value=None), pytest.raises( with (
intent.IntentHandleError patch("homeassistant.core.StateMachine.get", return_value=None),
pytest.raises(intent.IntentHandleError),
): ):
await intent.async_handle( await intent.async_handle(
hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {} hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {}
) )
with patch( with (
"homeassistant.core.StateMachine.async_all", return_value=[] patch("homeassistant.core.StateMachine.async_all", return_value=[]),
), pytest.raises(intent.IntentHandleError): pytest.raises(intent.NoStatesMatchedError) as error,
):
await intent.async_handle( await intent.async_handle(
hass, hass,
"test", "test",
climate_intent.INTENT_GET_TEMPERATURE, climate_intent.INTENT_GET_TEMPERATURE,
{"area": {"value": "Living Room"}}, {"area": {"value": "Living Room"}},
) )
# Exception should contain details of what we tried to match
assert isinstance(error.value, intent.NoStatesMatchedError)
assert error.value.name is None
assert error.value.area == "Living Room"
assert error.value.domains == {DOMAIN}
assert error.value.device_classes is None

View File

@ -1397,7 +1397,7 @@
'name': dict({ 'name': dict({
'name': 'name', 'name': 'name',
'text': 'my cool light', 'text': 'my cool light',
'value': 'light.kitchen', 'value': 'my cool light',
}), }),
}), }),
'intent': dict({ 'intent': dict({
@ -1422,7 +1422,7 @@
'name': dict({ 'name': dict({
'name': 'name', 'name': 'name',
'text': 'my cool light', 'text': 'my cool light',
'value': 'light.kitchen', 'value': 'my cool light',
}), }),
}), }),
'intent': dict({ 'intent': dict({
@ -1572,7 +1572,7 @@
'name': dict({ 'name': dict({
'name': 'name', 'name': 'name',
'text': 'test light', 'text': 'test light',
'value': 'light.demo_1234', 'value': 'test light',
}), }),
}), }),
'intent': dict({ 'intent': dict({
@ -1604,7 +1604,7 @@
'name': dict({ 'name': dict({
'name': 'name', 'name': 'name',
'text': 'test light', 'text': 'test light',
'value': 'light.demo_1234', 'value': 'test light',
}), }),
}), }),
'intent': dict({ 'intent': dict({

View File

@ -101,7 +101,7 @@ async def test_exposed_areas(
device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id) device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id)
kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234") kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234")
entity_registry.async_update_entity( kitchen_light = entity_registry.async_update_entity(
kitchen_light.entity_id, device_id=kitchen_device.id kitchen_light.entity_id, device_id=kitchen_device.id
) )
hass.states.async_set( hass.states.async_set(
@ -109,7 +109,7 @@ async def test_exposed_areas(
) )
bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678") bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678")
entity_registry.async_update_entity( bedroom_light = entity_registry.async_update_entity(
bedroom_light.entity_id, area_id=area_bedroom.id bedroom_light.entity_id, area_id=area_bedroom.id
) )
hass.states.async_set( hass.states.async_set(
@ -206,14 +206,14 @@ async def test_unexposed_entities_skipped(
# Both lights are in the kitchen # Both lights are in the kitchen
exposed_light = entity_registry.async_get_or_create("light", "demo", "1234") exposed_light = entity_registry.async_get_or_create("light", "demo", "1234")
entity_registry.async_update_entity( exposed_light = entity_registry.async_update_entity(
exposed_light.entity_id, exposed_light.entity_id,
area_id=area_kitchen.id, area_id=area_kitchen.id,
) )
hass.states.async_set(exposed_light.entity_id, "off") hass.states.async_set(exposed_light.entity_id, "off")
unexposed_light = entity_registry.async_get_or_create("light", "demo", "5678") unexposed_light = entity_registry.async_get_or_create("light", "demo", "5678")
entity_registry.async_update_entity( unexposed_light = entity_registry.async_update_entity(
unexposed_light.entity_id, unexposed_light.entity_id,
area_id=area_kitchen.id, area_id=area_kitchen.id,
) )
@ -336,7 +336,9 @@ async def test_device_area_context(
light_entity = entity_registry.async_get_or_create( light_entity = entity_registry.async_get_or_create(
"light", "demo", f"{area.name}-light-{i}" "light", "demo", f"{area.name}-light-{i}"
) )
entity_registry.async_update_entity(light_entity.entity_id, area_id=area.id) light_entity = entity_registry.async_update_entity(
light_entity.entity_id, area_id=area.id
)
hass.states.async_set( hass.states.async_set(
light_entity.entity_id, light_entity.entity_id,
"off", "off",
@ -612,6 +614,115 @@ async def test_error_no_intent(hass: HomeAssistant, init_components) -> None:
) )
async def test_error_duplicate_names(
hass: HomeAssistant, init_components, entity_registry: er.EntityRegistry
) -> None:
"""Test error message when multiple devices have the same name (or alias)."""
kitchen_light_1 = entity_registry.async_get_or_create("light", "demo", "1234")
kitchen_light_2 = entity_registry.async_get_or_create("light", "demo", "5678")
# Same name and alias
for light in (kitchen_light_1, kitchen_light_2):
light = entity_registry.async_update_entity(
light.entity_id,
name="kitchen light",
aliases={"overhead light"},
)
hass.states.async_set(
light.entity_id,
"off",
attributes={ATTR_FRIENDLY_NAME: light.name},
)
# Check name and alias
for name in ("kitchen light", "overhead light"):
# command
result = await conversation.async_converse(
hass, f"turn on {name}", None, Context(), None
)
assert result.response.response_type == intent.IntentResponseType.ERROR
assert (
result.response.error_code
== intent.IntentResponseErrorCode.NO_VALID_TARGETS
)
assert (
result.response.speech["plain"]["speech"]
== f"Sorry, there are multiple devices called {name}"
)
# question
result = await conversation.async_converse(
hass, f"is {name} on?", None, Context(), None
)
assert result.response.response_type == intent.IntentResponseType.ERROR
assert (
result.response.error_code
== intent.IntentResponseErrorCode.NO_VALID_TARGETS
)
assert (
result.response.speech["plain"]["speech"]
== f"Sorry, there are multiple devices called {name}"
)
async def test_error_duplicate_names_in_area(
hass: HomeAssistant,
init_components,
area_registry: ar.AreaRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test error message when multiple devices have the same name (or alias)."""
area_kitchen = area_registry.async_get_or_create("kitchen_id")
area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen")
kitchen_light_1 = entity_registry.async_get_or_create("light", "demo", "1234")
kitchen_light_2 = entity_registry.async_get_or_create("light", "demo", "5678")
# Same name and alias
for light in (kitchen_light_1, kitchen_light_2):
light = entity_registry.async_update_entity(
light.entity_id,
name="kitchen light",
area_id=area_kitchen.id,
aliases={"overhead light"},
)
hass.states.async_set(
light.entity_id,
"off",
attributes={ATTR_FRIENDLY_NAME: light.name},
)
# Check name and alias
for name in ("kitchen light", "overhead light"):
# command
result = await conversation.async_converse(
hass, f"turn on {name} in {area_kitchen.name}", None, Context(), None
)
assert result.response.response_type == intent.IntentResponseType.ERROR
assert (
result.response.error_code
== intent.IntentResponseErrorCode.NO_VALID_TARGETS
)
assert (
result.response.speech["plain"]["speech"]
== f"Sorry, there are multiple devices called {name} in the {area_kitchen.name} area"
)
# question
result = await conversation.async_converse(
hass, f"is {name} on in the {area_kitchen.name}?", None, Context(), None
)
assert result.response.response_type == intent.IntentResponseType.ERROR
assert (
result.response.error_code
== intent.IntentResponseErrorCode.NO_VALID_TARGETS
)
assert (
result.response.speech["plain"]["speech"]
== f"Sorry, there are multiple devices called {name} in the {area_kitchen.name} area"
)
async def test_no_states_matched_default_error( async def test_no_states_matched_default_error(
hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry
) -> None: ) -> None:
@ -692,7 +803,7 @@ async def test_empty_aliases(
names = slot_lists["name"] names = slot_lists["name"]
assert len(names.values) == 1 assert len(names.values) == 1
assert names.values[0].value_out == kitchen_light.entity_id assert names.values[0].value_out == kitchen_light.name
assert names.values[0].text_in.text == kitchen_light.name assert names.values[0].text_in.text == kitchen_light.name
@ -713,3 +824,191 @@ async def test_all_domains_loaded(hass: HomeAssistant, init_components) -> None:
result.response.speech["plain"]["speech"] result.response.speech["plain"]["speech"]
== "Sorry, I am not aware of any device called test light" == "Sorry, I am not aware of any device called test light"
) )
async def test_same_named_entities_in_different_areas(
hass: HomeAssistant,
init_components,
area_registry: ar.AreaRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that entities with the same name in different areas can be targeted."""
area_kitchen = area_registry.async_get_or_create("kitchen_id")
area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen")
area_bedroom = area_registry.async_get_or_create("bedroom_id")
area_bedroom = area_registry.async_update(area_bedroom.id, name="bedroom")
# Both lights have the same name, but are in different areas
kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234")
kitchen_light = entity_registry.async_update_entity(
kitchen_light.entity_id,
area_id=area_kitchen.id,
name="overhead light",
)
hass.states.async_set(
kitchen_light.entity_id,
"off",
attributes={ATTR_FRIENDLY_NAME: kitchen_light.name},
)
bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678")
bedroom_light = entity_registry.async_update_entity(
bedroom_light.entity_id,
area_id=area_bedroom.id,
name="overhead light",
)
hass.states.async_set(
bedroom_light.entity_id,
"off",
attributes={ATTR_FRIENDLY_NAME: bedroom_light.name},
)
# Target kitchen light
calls = async_mock_service(hass, "light", "turn_on")
result = await conversation.async_converse(
hass, "turn on overhead light in the kitchen", None, Context(), None
)
await hass.async_block_till_done()
assert len(calls) == 1
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert result.response.intent is not None
assert (
result.response.intent.slots.get("name", {}).get("value") == kitchen_light.name
)
assert (
result.response.intent.slots.get("name", {}).get("text") == kitchen_light.name
)
assert len(result.response.matched_states) == 1
assert result.response.matched_states[0].entity_id == kitchen_light.entity_id
assert calls[0].data.get("entity_id") == [kitchen_light.entity_id]
# Target bedroom light
calls.clear()
result = await conversation.async_converse(
hass, "turn on overhead light in the bedroom", None, Context(), None
)
await hass.async_block_till_done()
assert len(calls) == 1
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert result.response.intent is not None
assert (
result.response.intent.slots.get("name", {}).get("value") == bedroom_light.name
)
assert (
result.response.intent.slots.get("name", {}).get("text") == bedroom_light.name
)
assert len(result.response.matched_states) == 1
assert result.response.matched_states[0].entity_id == bedroom_light.entity_id
assert calls[0].data.get("entity_id") == [bedroom_light.entity_id]
# Targeting a duplicate name should fail
result = await conversation.async_converse(
hass, "turn on overhead light", None, Context(), None
)
assert result.response.response_type == intent.IntentResponseType.ERROR
# Querying a duplicate name should also fail
result = await conversation.async_converse(
hass, "is the overhead light on?", None, Context(), None
)
assert result.response.response_type == intent.IntentResponseType.ERROR
# But we can still ask questions that don't rely on the name
result = await conversation.async_converse(
hass, "how many lights are on?", None, Context(), None
)
assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER
async def test_same_aliased_entities_in_different_areas(
hass: HomeAssistant,
init_components,
area_registry: ar.AreaRegistry,
entity_registry: er.EntityRegistry,
) -> None:
"""Test that entities with the same alias (but different names) in different areas can be targeted."""
area_kitchen = area_registry.async_get_or_create("kitchen_id")
area_kitchen = area_registry.async_update(area_kitchen.id, name="kitchen")
area_bedroom = area_registry.async_get_or_create("bedroom_id")
area_bedroom = area_registry.async_update(area_bedroom.id, name="bedroom")
# Both lights have the same alias, but are in different areas
kitchen_light = entity_registry.async_get_or_create("light", "demo", "1234")
kitchen_light = entity_registry.async_update_entity(
kitchen_light.entity_id,
area_id=area_kitchen.id,
name="kitchen overhead light",
aliases={"overhead light"},
)
hass.states.async_set(
kitchen_light.entity_id,
"off",
attributes={ATTR_FRIENDLY_NAME: kitchen_light.name},
)
bedroom_light = entity_registry.async_get_or_create("light", "demo", "5678")
bedroom_light = entity_registry.async_update_entity(
bedroom_light.entity_id,
area_id=area_bedroom.id,
name="bedroom overhead light",
aliases={"overhead light"},
)
hass.states.async_set(
bedroom_light.entity_id,
"off",
attributes={ATTR_FRIENDLY_NAME: bedroom_light.name},
)
# Target kitchen light
calls = async_mock_service(hass, "light", "turn_on")
result = await conversation.async_converse(
hass, "turn on overhead light in the kitchen", None, Context(), None
)
await hass.async_block_till_done()
assert len(calls) == 1
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert result.response.intent is not None
assert result.response.intent.slots.get("name", {}).get("value") == "overhead light"
assert result.response.intent.slots.get("name", {}).get("text") == "overhead light"
assert len(result.response.matched_states) == 1
assert result.response.matched_states[0].entity_id == kitchen_light.entity_id
assert calls[0].data.get("entity_id") == [kitchen_light.entity_id]
# Target bedroom light
calls.clear()
result = await conversation.async_converse(
hass, "turn on overhead light in the bedroom", None, Context(), None
)
await hass.async_block_till_done()
assert len(calls) == 1
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert result.response.intent is not None
assert result.response.intent.slots.get("name", {}).get("value") == "overhead light"
assert result.response.intent.slots.get("name", {}).get("text") == "overhead light"
assert len(result.response.matched_states) == 1
assert result.response.matched_states[0].entity_id == bedroom_light.entity_id
assert calls[0].data.get("entity_id") == [bedroom_light.entity_id]
# Targeting a duplicate alias should fail
result = await conversation.async_converse(
hass, "turn on overhead light", None, Context(), None
)
assert result.response.response_type == intent.IntentResponseType.ERROR
# Querying a duplicate alias should also fail
result = await conversation.async_converse(
hass, "is the overhead light on?", None, Context(), None
)
assert result.response.response_type == intent.IntentResponseType.ERROR
# But we can still ask questions that don't rely on the alias
result = await conversation.async_converse(
hass, "how many lights are on?", None, Context(), None
)
assert result.response.response_type == intent.IntentResponseType.QUERY_ANSWER

View File

@ -1,4 +1,7 @@
"""Test conversation triggers.""" """Test conversation triggers."""
import logging
import pytest import pytest
import voluptuous as vol import voluptuous as vol
@ -70,7 +73,7 @@ async def test_if_fires_on_event(hass: HomeAssistant, calls, setup_comp) -> None
async def test_response(hass: HomeAssistant, setup_comp) -> None: async def test_response(hass: HomeAssistant, setup_comp) -> None:
"""Test the firing of events.""" """Test the conversation response action."""
response = "I'm sorry, Dave. I'm afraid I can't do that" response = "I'm sorry, Dave. I'm afraid I can't do that"
assert await async_setup_component( assert await async_setup_component(
hass, hass,
@ -100,6 +103,116 @@ async def test_response(hass: HomeAssistant, setup_comp) -> None:
assert service_response["response"]["speech"]["plain"]["speech"] == response assert service_response["response"]["speech"]["plain"]["speech"] == response
async def test_response_same_sentence(hass: HomeAssistant, calls, setup_comp) -> None:
"""Test the conversation response action with multiple triggers using the same sentence."""
assert await async_setup_component(
hass,
"automation",
{
"automation": [
{
"trigger": {
"id": "trigger1",
"platform": "conversation",
"command": ["test sentence"],
},
"action": [
# Add delay so this response will not be the first
{"delay": "0:0:0.100"},
{
"service": "test.automation",
"data_template": {"data": "{{ trigger }}"},
},
{"set_conversation_response": "response 2"},
],
},
{
"trigger": {
"id": "trigger2",
"platform": "conversation",
"command": ["test sentence"],
},
"action": {"set_conversation_response": "response 1"},
},
]
},
)
service_response = await hass.services.async_call(
"conversation",
"process",
{"text": "test sentence"},
blocking=True,
return_response=True,
)
await hass.async_block_till_done()
# Should only get first response
assert service_response["response"]["speech"]["plain"]["speech"] == "response 1"
# Service should still have been called
assert len(calls) == 1
assert calls[0].data["data"] == {
"alias": None,
"id": "trigger1",
"idx": "0",
"platform": "conversation",
"sentence": "test sentence",
"slots": {},
"details": {},
}
async def test_response_same_sentence_with_error(
hass: HomeAssistant, calls, setup_comp, caplog: pytest.LogCaptureFixture
) -> None:
"""Test the conversation response action with multiple triggers using the same sentence and an error."""
caplog.set_level(logging.ERROR)
assert await async_setup_component(
hass,
"automation",
{
"automation": [
{
"trigger": {
"id": "trigger1",
"platform": "conversation",
"command": ["test sentence"],
},
"action": [
# Add delay so this will not finish first
{"delay": "0:0:0.100"},
{"service": "fake_domain.fake_service"},
],
},
{
"trigger": {
"id": "trigger2",
"platform": "conversation",
"command": ["test sentence"],
},
"action": {"set_conversation_response": "response 1"},
},
]
},
)
service_response = await hass.services.async_call(
"conversation",
"process",
{"text": "test sentence"},
blocking=True,
return_response=True,
)
await hass.async_block_till_done()
# Should still get first response
assert service_response["response"]["speech"]["plain"]["speech"] == "response 1"
# Error should have been logged
assert "Error executing script" in caplog.text
async def test_subscribe_trigger_does_not_interfere_with_responses( async def test_subscribe_trigger_does_not_interfere_with_responses(
hass: HomeAssistant, setup_comp, hass_ws_client: WebSocketGenerator hass: HomeAssistant, setup_comp, hass_ws_client: WebSocketGenerator
) -> None: ) -> None:

View File

@ -1,7 +1,9 @@
"""Test the Emulated Hue component.""" """Test the Emulated Hue component."""
from datetime import timedelta from datetime import timedelta
from typing import Any from typing import Any
from unittest.mock import AsyncMock, patch from unittest.mock import AsyncMock, Mock, patch
from aiohttp import web
from homeassistant.components.emulated_hue.config import ( from homeassistant.components.emulated_hue.config import (
DATA_KEY, DATA_KEY,
@ -135,6 +137,9 @@ async def test_setup_works(hass: HomeAssistant) -> None:
AsyncMock(), AsyncMock(),
) as mock_create_upnp_datagram_endpoint, patch( ) as mock_create_upnp_datagram_endpoint, patch(
"homeassistant.components.emulated_hue.async_get_source_ip" "homeassistant.components.emulated_hue.async_get_source_ip"
), patch(
"homeassistant.components.emulated_hue.web.TCPSite",
return_value=Mock(spec_set=web.TCPSite),
): ):
mock_create_upnp_datagram_endpoint.return_value = AsyncMock( mock_create_upnp_datagram_endpoint.return_value = AsyncMock(
spec=UPNPResponderProtocol spec=UPNPResponderProtocol

View File

@ -293,7 +293,7 @@ async def test_setup_api_push_api_data(
assert aioclient_mock.call_count == 19 assert aioclient_mock.call_count == 19
assert not aioclient_mock.mock_calls[1][2]["ssl"] assert not aioclient_mock.mock_calls[1][2]["ssl"]
assert aioclient_mock.mock_calls[1][2]["port"] == 9999 assert aioclient_mock.mock_calls[1][2]["port"] == 9999
assert aioclient_mock.mock_calls[1][2]["watchdog"] assert "watchdog" not in aioclient_mock.mock_calls[1][2]
async def test_setup_api_push_api_data_server_host( async def test_setup_api_push_api_data_server_host(

View File

@ -1,4 +1,5 @@
"""Tests for Intent component.""" """Tests for Intent component."""
import pytest import pytest
from homeassistant.components.cover import SERVICE_OPEN_COVER from homeassistant.components.cover import SERVICE_OPEN_COVER
@ -225,6 +226,30 @@ async def test_turn_on_multiple_intent(hass: HomeAssistant) -> None:
assert call.data == {"entity_id": ["light.test_lights_2"]} assert call.data == {"entity_id": ["light.test_lights_2"]}
async def test_turn_on_all(hass: HomeAssistant) -> None:
"""Test HassTurnOn intent with "all" name."""
result = await async_setup_component(hass, "homeassistant", {})
result = await async_setup_component(hass, "intent", {})
assert result
hass.states.async_set("light.test_light", "off")
hass.states.async_set("light.test_light_2", "off")
calls = async_mock_service(hass, "light", SERVICE_TURN_ON)
await intent.async_handle(hass, "test", "HassTurnOn", {"name": {"value": "all"}})
await hass.async_block_till_done()
# All lights should be on now
assert len(calls) == 2
entity_ids = set()
for call in calls:
assert call.domain == "light"
assert call.service == "turn_on"
entity_ids.update(call.data.get("entity_id", []))
assert entity_ids == {"light.test_light", "light.test_light_2"}
async def test_get_state_intent( async def test_get_state_intent(
hass: HomeAssistant, hass: HomeAssistant,
area_registry: ar.AreaRegistry, area_registry: ar.AreaRegistry,

View File

@ -144,10 +144,10 @@ async def test_node_added_subscription(
integration: MagicMock, integration: MagicMock,
) -> None: ) -> None:
"""Test subscription to new devices work.""" """Test subscription to new devices work."""
assert matter_client.subscribe_events.call_count == 4 assert matter_client.subscribe_events.call_count == 5
assert ( assert (
matter_client.subscribe_events.call_args.kwargs["event_filter"] matter_client.subscribe_events.call_args.kwargs["event_filter"]
== EventType.NODE_ADDED == EventType.NODE_UPDATED
) )
node_added_callback = matter_client.subscribe_events.call_args.kwargs["callback"] node_added_callback = matter_client.subscribe_events.call_args.kwargs["callback"]

View File

@ -229,6 +229,7 @@ async def test_node_diagnostics(
mac_address="00:11:22:33:44:55", mac_address="00:11:22:33:44:55",
available=True, available=True,
active_fabrics=[MatterFabricData(2, 4939, 1, vendor_name="Nabu Casa")], active_fabrics=[MatterFabricData(2, 4939, 1, vendor_name="Nabu Casa")],
active_fabric_index=0,
) )
matter_client.node_diagnostics = AsyncMock(return_value=mock_diagnostics) matter_client.node_diagnostics = AsyncMock(return_value=mock_diagnostics)

View File

@ -42,6 +42,8 @@ from homeassistant.components.modbus.const import (
CONF_HVAC_MODE_REGISTER, CONF_HVAC_MODE_REGISTER,
CONF_HVAC_MODE_VALUES, CONF_HVAC_MODE_VALUES,
CONF_HVAC_ONOFF_REGISTER, CONF_HVAC_ONOFF_REGISTER,
CONF_MAX_TEMP,
CONF_MIN_TEMP,
CONF_TARGET_TEMP, CONF_TARGET_TEMP,
CONF_TARGET_TEMP_WRITE_REGISTERS, CONF_TARGET_TEMP_WRITE_REGISTERS,
CONF_WRITE_REGISTERS, CONF_WRITE_REGISTERS,
@ -170,6 +172,30 @@ ENTITY_ID = f"{CLIMATE_DOMAIN}.{TEST_ENTITY_NAME}".replace(" ", "_")
} }
], ],
}, },
{
CONF_CLIMATES: [
{
CONF_NAME: TEST_ENTITY_NAME,
CONF_TARGET_TEMP: 117,
CONF_ADDRESS: 117,
CONF_SLAVE: 10,
CONF_MIN_TEMP: 23,
CONF_MAX_TEMP: 57,
}
],
},
{
CONF_CLIMATES: [
{
CONF_NAME: TEST_ENTITY_NAME,
CONF_TARGET_TEMP: 117,
CONF_ADDRESS: 117,
CONF_SLAVE: 10,
CONF_MIN_TEMP: -57,
CONF_MAX_TEMP: -23,
}
],
},
], ],
) )
async def test_config_climate(hass: HomeAssistant, mock_modbus) -> None: async def test_config_climate(hass: HomeAssistant, mock_modbus) -> None:

View File

@ -185,6 +185,28 @@ SLAVE_UNIQUE_ID = "ground_floor_sensor"
} }
] ]
}, },
{
CONF_SENSORS: [
{
CONF_NAME: TEST_ENTITY_NAME,
CONF_ADDRESS: 51,
CONF_DATA_TYPE: DataType.INT16,
CONF_MIN_VALUE: 1,
CONF_MAX_VALUE: 3,
}
]
},
{
CONF_SENSORS: [
{
CONF_NAME: TEST_ENTITY_NAME,
CONF_ADDRESS: 51,
CONF_DATA_TYPE: DataType.INT16,
CONF_MIN_VALUE: -3,
CONF_MAX_VALUE: -1,
}
]
},
], ],
) )
async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None: async def test_config_sensor(hass: HomeAssistant, mock_modbus) -> None:
@ -688,6 +710,16 @@ async def test_config_wrong_struct_sensor(
False, False,
"112594", "112594",
), ),
(
{
CONF_DATA_TYPE: DataType.INT16,
CONF_SCALE: -1,
CONF_OFFSET: 0,
},
[0x000A],
False,
"-10",
),
], ],
) )
async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None: async def test_all_sensor(hass: HomeAssistant, mock_do_cycle, expected) -> None:

View File

@ -4257,3 +4257,64 @@ async def test_update_entry_and_reload(
assert entry.state == config_entries.ConfigEntryState.LOADED assert entry.state == config_entries.ConfigEntryState.LOADED
assert task["type"] == FlowResultType.ABORT assert task["type"] == FlowResultType.ABORT
assert task["reason"] == "reauth_successful" assert task["reason"] == "reauth_successful"
@pytest.mark.parametrize("unique_id", [["blah", "bleh"], {"key": "value"}])
async def test_unhashable_unique_id(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, unique_id: Any
) -> None:
"""Test the ConfigEntryItems user dict handles unhashable unique_id."""
entries = config_entries.ConfigEntryItems(hass)
entry = config_entries.ConfigEntry(
version=1,
minor_version=1,
domain="test",
entry_id="mock_id",
title="title",
data={},
source="test",
unique_id=unique_id,
)
entries[entry.entry_id] = entry
assert (
"Config entry 'title' from integration test has an invalid unique_id "
f"'{str(unique_id)}'"
) in caplog.text
assert entry.entry_id in entries
assert entries[entry.entry_id] is entry
assert entries.get_entry_by_domain_and_unique_id("test", unique_id) == entry
del entries[entry.entry_id]
assert not entries
assert entries.get_entry_by_domain_and_unique_id("test", unique_id) is None
@pytest.mark.parametrize("unique_id", [123])
async def test_hashable_non_string_unique_id(
hass: HomeAssistant, caplog: pytest.LogCaptureFixture, unique_id: Any
) -> None:
"""Test the ConfigEntryItems user dict handles hashable non string unique_id."""
entries = config_entries.ConfigEntryItems(hass)
entry = config_entries.ConfigEntry(
version=1,
minor_version=1,
domain="test",
entry_id="mock_id",
title="title",
data={},
source="test",
unique_id=unique_id,
)
entries[entry.entry_id] = entry
assert (
"Config entry 'title' from integration test has an invalid unique_id"
) not in caplog.text
assert entry.entry_id in entries
assert entries[entry.entry_id] is entry
assert entries.get_entry_by_domain_and_unique_id("test", unique_id) == entry
del entries[entry.entry_id]
assert not entries
assert entries.get_entry_by_domain_and_unique_id("test", unique_id) is None