mirror of
https://github.com/home-assistant/core.git
synced 2025-07-23 05:07:41 +00:00
2024.2.1 (#110078)
This commit is contained in:
commit
cfd1f7809f
@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/aosmith",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["py-aosmith==1.0.6"]
|
||||
"requirements": ["py-aosmith==1.0.8"]
|
||||
}
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""Intents for the client integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import voluptuous as vol
|
||||
@ -36,24 +37,34 @@ class GetTemperatureIntent(intent.IntentHandler):
|
||||
if not entities:
|
||||
raise intent.IntentHandleError("No climate entities")
|
||||
|
||||
if "area" in slots:
|
||||
# Filter by area
|
||||
area_name = slots["area"]["value"]
|
||||
name_slot = slots.get("name", {})
|
||||
entity_name: str | None = name_slot.get("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(
|
||||
hass, area_name=area_name, domains=[DOMAIN]
|
||||
hass, name=entity_name, area_name=area_id, domains=[DOMAIN]
|
||||
):
|
||||
climate_state = maybe_climate
|
||||
break
|
||||
|
||||
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)
|
||||
elif "name" in slots:
|
||||
elif entity_name:
|
||||
# Filter by name
|
||||
entity_name = slots["name"]["value"]
|
||||
|
||||
for maybe_climate in intent.async_match_states(
|
||||
hass, name=entity_name, domains=[DOMAIN]
|
||||
):
|
||||
@ -61,7 +72,12 @@ class GetTemperatureIntent(intent.IntentHandler):
|
||||
break
|
||||
|
||||
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)
|
||||
else:
|
||||
|
@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioelectricitymaps"],
|
||||
"requirements": ["aioelectricitymaps==0.3.1"]
|
||||
"requirements": ["aioelectricitymaps==0.4.0"]
|
||||
}
|
||||
|
@ -223,22 +223,22 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
# Check if a trigger matched
|
||||
if isinstance(result, SentenceTriggerResult):
|
||||
# Gather callback responses in parallel
|
||||
trigger_responses = await asyncio.gather(
|
||||
*(
|
||||
self._trigger_sentences[trigger_id].callback(
|
||||
result.sentence, trigger_result
|
||||
)
|
||||
for trigger_id, trigger_result in result.matched_triggers.items()
|
||||
trigger_callbacks = [
|
||||
self._trigger_sentences[trigger_id].callback(
|
||||
result.sentence, trigger_result
|
||||
)
|
||||
)
|
||||
for trigger_id, trigger_result in result.matched_triggers.items()
|
||||
]
|
||||
|
||||
# Use last non-empty result as response.
|
||||
#
|
||||
# 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.
|
||||
response_text: str | None = None
|
||||
for trigger_response in trigger_responses:
|
||||
response_text = response_text or trigger_response
|
||||
for trigger_future in asyncio.as_completed(trigger_callbacks):
|
||||
if trigger_response := await trigger_future:
|
||||
response_text = trigger_response
|
||||
break
|
||||
|
||||
# Convert to conversation result
|
||||
response = intent.IntentResponse(language=language)
|
||||
@ -316,6 +316,20 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
),
|
||||
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:
|
||||
# Intent was valid and entities matched constraints, but an error
|
||||
# occurred during handling.
|
||||
@ -724,7 +738,12 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
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 = []
|
||||
for state in states:
|
||||
# Checked against "requires_context" and "excludes_context" in hassil
|
||||
@ -740,7 +759,7 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
|
||||
if not entity:
|
||||
# Default name
|
||||
entity_names.append((state.name, state.entity_id, context))
|
||||
entity_names.append((state.name, state.name, context))
|
||||
continue
|
||||
|
||||
if entity.aliases:
|
||||
@ -748,12 +767,15 @@ class DefaultAgent(AbstractConversationAgent):
|
||||
if not alias.strip():
|
||||
continue
|
||||
|
||||
entity_names.append((alias, state.entity_id, context))
|
||||
entity_names.append((alias, alias, context))
|
||||
|
||||
# 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)
|
||||
area_names = []
|
||||
for area in areas.async_list_areas():
|
||||
@ -984,6 +1006,20 @@ def _get_no_states_matched_response(
|
||||
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:
|
||||
"""Collect list reference names recursively."""
|
||||
if isinstance(expression, Sequence):
|
||||
|
@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecovacs",
|
||||
"iot_class": "cloud_push",
|
||||
"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"]
|
||||
}
|
||||
|
@ -6,5 +6,5 @@
|
||||
"dependencies": ["webhook"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/ecowitt",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["aioecowitt==2024.2.0"]
|
||||
"requirements": ["aioecowitt==2024.2.1"]
|
||||
}
|
||||
|
@ -5,5 +5,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/evohome",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["evohomeasync", "evohomeasync2"],
|
||||
"requirements": ["evohome-async==0.4.17"]
|
||||
"requirements": ["evohome-async==0.4.18"]
|
||||
}
|
||||
|
@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20240207.0"]
|
||||
"requirements": ["home-assistant-frontend==20240207.1"]
|
||||
}
|
||||
|
@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aio_geojson_geonetnz_volcano"],
|
||||
"requirements": ["aio-geojson-geonetnz-volcano==0.8"]
|
||||
"requirements": ["aio-geojson-geonetnz-volcano==0.9"]
|
||||
}
|
||||
|
@ -506,7 +506,6 @@ class HassIO:
|
||||
options = {
|
||||
"ssl": CONF_SSL_CERTIFICATE in http_config,
|
||||
"port": port,
|
||||
"watchdog": True,
|
||||
"refresh_token": refresh_token.token,
|
||||
}
|
||||
|
||||
|
@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from aiohttp import ClientConnectionError
|
||||
from aiosomecomfort import (
|
||||
APIRateLimited,
|
||||
AuthError,
|
||||
ConnectionError as AscConnectionError,
|
||||
SomeComfortError,
|
||||
@ -505,10 +506,11 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
await self._device.refresh()
|
||||
|
||||
except (
|
||||
asyncio.TimeoutError,
|
||||
AscConnectionError,
|
||||
APIRateLimited,
|
||||
AuthError,
|
||||
ClientConnectionError,
|
||||
AscConnectionError,
|
||||
asyncio.TimeoutError,
|
||||
):
|
||||
self._retry += 1
|
||||
self._attr_available = self._retry <= RETRY
|
||||
@ -524,7 +526,12 @@ class HoneywellUSThermostat(ClimateEntity):
|
||||
await _login()
|
||||
return
|
||||
|
||||
except (AscConnectionError, ClientConnectionError, asyncio.TimeoutError):
|
||||
except (
|
||||
asyncio.TimeoutError,
|
||||
AscConnectionError,
|
||||
APIRateLimited,
|
||||
ClientConnectionError,
|
||||
):
|
||||
self._retry += 1
|
||||
self._attr_available = self._retry <= RETRY
|
||||
return
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""The Intent integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@ -155,16 +156,18 @@ class GetStateIntentHandler(intent.IntentHandler):
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
# 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
|
||||
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
|
||||
if area_name is not None:
|
||||
if area_id is not None:
|
||||
areas = ar.async_get(hass)
|
||||
area = areas.async_get_area(area_name) or areas.async_get_area_by_name(
|
||||
area_name
|
||||
)
|
||||
area = areas.async_get_area(area_id)
|
||||
if area is None:
|
||||
raise intent.IntentHandleError(f"No area named {area_name}")
|
||||
|
||||
@ -186,7 +189,7 @@ class GetStateIntentHandler(intent.IntentHandler):
|
||||
states = list(
|
||||
intent.async_match_states(
|
||||
hass,
|
||||
name=name,
|
||||
name=entity_name,
|
||||
area=area,
|
||||
domains=domains,
|
||||
device_classes=device_classes,
|
||||
@ -197,13 +200,20 @@ class GetStateIntentHandler(intent.IntentHandler):
|
||||
_LOGGER.debug(
|
||||
"Found %s state(s) that matched: name=%s, area=%s, domains=%s, device_classes=%s, assistant=%s",
|
||||
len(states),
|
||||
name,
|
||||
entity_name,
|
||||
area,
|
||||
domains,
|
||||
device_classes,
|
||||
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
|
||||
response = intent_obj.create_response()
|
||||
response.response_type = intent.IntentResponseType.QUERY_ANSWER
|
||||
|
@ -16,5 +16,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "assumed_state",
|
||||
"loggers": ["keymitt_ble"],
|
||||
"requirements": ["PyMicroBot==0.0.10"]
|
||||
"requirements": ["PyMicroBot==0.0.12"]
|
||||
}
|
||||
|
@ -52,11 +52,27 @@ class MatterAdapter:
|
||||
|
||||
async def setup_nodes(self) -> None:
|
||||
"""Set up all existing nodes and subscribe to new nodes."""
|
||||
initialized_nodes: set[int] = set()
|
||||
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)
|
||||
|
||||
def node_added_callback(event: EventType, node: MatterNode) -> None:
|
||||
"""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)
|
||||
|
||||
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
|
||||
)
|
||||
)
|
||||
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:
|
||||
"""Set up an node."""
|
||||
|
@ -129,6 +129,9 @@ class MatterEntity(Entity):
|
||||
|
||||
async def async_update(self) -> None:
|
||||
"""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
|
||||
await self.matter_client.refresh_attribute(
|
||||
self._endpoint.node.node_id,
|
||||
|
@ -6,5 +6,5 @@
|
||||
"dependencies": ["websocket_api"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/matter",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["python-matter-server==5.4.1"]
|
||||
"requirements": ["python-matter-server==5.5.0"]
|
||||
}
|
||||
|
@ -186,7 +186,7 @@ BASE_STRUCT_SCHEMA = BASE_COMPONENT_SCHEMA.extend(
|
||||
]
|
||||
),
|
||||
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_PRECISION): cv.positive_int,
|
||||
vol.Optional(
|
||||
@ -241,8 +241,8 @@ CLIMATE_SCHEMA = vol.All(
|
||||
{
|
||||
vol.Required(CONF_TARGET_TEMP): cv.positive_int,
|
||||
vol.Optional(CONF_TARGET_TEMP_WRITE_REGISTERS, default=False): cv.boolean,
|
||||
vol.Optional(CONF_MAX_TEMP, default=35): cv.positive_float,
|
||||
vol.Optional(CONF_MIN_TEMP, default=5): cv.positive_float,
|
||||
vol.Optional(CONF_MAX_TEMP, default=35): vol.Coerce(float),
|
||||
vol.Optional(CONF_MIN_TEMP, default=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_HVAC_ONOFF_REGISTER): cv.positive_int,
|
||||
@ -342,8 +342,8 @@ SENSOR_SCHEMA = vol.All(
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): cv.string,
|
||||
vol.Exclusive(CONF_VIRTUAL_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_MAX_VALUE): cv.positive_float,
|
||||
vol.Optional(CONF_MIN_VALUE): vol.Coerce(float),
|
||||
vol.Optional(CONF_MAX_VALUE): vol.Coerce(float),
|
||||
vol.Optional(CONF_NAN_VALUE): nan_validator,
|
||||
vol.Optional(CONF_ZERO_SUPPRESS): cv.positive_float,
|
||||
}
|
||||
|
@ -364,7 +364,9 @@ class ModbusThermostat(BaseStructPlatform, RestoreEntity, ClimateEntity):
|
||||
|
||||
# Translate the value received
|
||||
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
|
||||
# register is "OFF", it will take precedence over the value
|
||||
|
@ -7,6 +7,7 @@ from collections.abc import (
|
||||
Callable,
|
||||
Coroutine,
|
||||
Generator,
|
||||
Hashable,
|
||||
Iterable,
|
||||
Mapping,
|
||||
ValuesView,
|
||||
@ -49,6 +50,7 @@ from .helpers.event import (
|
||||
)
|
||||
from .helpers.frame import report
|
||||
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 .util import uuid as uuid_util
|
||||
from .util.decorator import Registry
|
||||
@ -1124,9 +1126,10 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
|
||||
- domain -> unique_id -> ConfigEntry
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize the container."""
|
||||
super().__init__()
|
||||
self._hass = hass
|
||||
self._domain_index: dict[str, list[ConfigEntry]] = {}
|
||||
self._domain_unique_id_index: dict[str, dict[str, ConfigEntry]] = {}
|
||||
|
||||
@ -1145,8 +1148,27 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
|
||||
data[entry_id] = entry
|
||||
self._domain_index.setdefault(entry.domain, []).append(entry)
|
||||
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, {})[
|
||||
entry.unique_id
|
||||
unique_id_hash
|
||||
] = entry
|
||||
|
||||
def _unindex_entry(self, entry_id: str) -> None:
|
||||
@ -1157,6 +1179,9 @@ class ConfigEntryItems(UserDict[str, ConfigEntry]):
|
||||
if not self._domain_index[domain]:
|
||||
del self._domain_index[domain]
|
||||
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]
|
||||
if not 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
|
||||
) -> ConfigEntry | None:
|
||||
"""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)
|
||||
|
||||
|
||||
@ -1189,7 +1217,7 @@ class ConfigEntries:
|
||||
self.flow = ConfigEntriesFlowManager(hass, self, hass_config)
|
||||
self.options = OptionsFlowManager(hass)
|
||||
self._hass_config = hass_config
|
||||
self._entries = ConfigEntryItems()
|
||||
self._entries = ConfigEntryItems(hass)
|
||||
self._store = storage.Store[dict[str, list[dict[str, Any]]]](
|
||||
hass, STORAGE_VERSION, STORAGE_KEY
|
||||
)
|
||||
@ -1314,10 +1342,10 @@ class ConfigEntries:
|
||||
self.hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, self._async_shutdown)
|
||||
|
||||
if config is None:
|
||||
self._entries = ConfigEntryItems()
|
||||
self._entries = ConfigEntryItems(self.hass)
|
||||
return
|
||||
|
||||
entries: ConfigEntryItems = ConfigEntryItems()
|
||||
entries: ConfigEntryItems = ConfigEntryItems(self.hass)
|
||||
for entry in config["entries"]:
|
||||
pref_disable_new_entities = entry.get("pref_disable_new_entities")
|
||||
|
||||
|
@ -16,7 +16,7 @@ from .helpers.deprecation import (
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2024
|
||||
MINOR_VERSION: Final = 2
|
||||
PATCH_VERSION: Final = "0"
|
||||
PATCH_VERSION: Final = "1"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0)
|
||||
|
@ -155,6 +155,17 @@ class NoStatesMatchedError(IntentError):
|
||||
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(
|
||||
state: State,
|
||||
entity: entity_registry.RegistryEntry | None,
|
||||
@ -318,8 +329,6 @@ def async_match_states(
|
||||
for state, entity in states_and_entities:
|
||||
if _has_name(state, entity, name):
|
||||
yield state
|
||||
break
|
||||
|
||||
else:
|
||||
# Not filtered by name
|
||||
for state, _entity in states_and_entities:
|
||||
@ -403,11 +412,11 @@ class ServiceIntentHandler(IntentHandler):
|
||||
slots = self.async_validate_slots(intent_obj.slots)
|
||||
|
||||
name_slot = slots.get("name", {})
|
||||
entity_id: str | None = name_slot.get("value")
|
||||
entity_name: str | None = name_slot.get("text")
|
||||
if entity_id == "all":
|
||||
entity_name: str | None = name_slot.get("value")
|
||||
entity_text: str | None = name_slot.get("text")
|
||||
if entity_name == "all":
|
||||
# Don't match on name if targeting all entities
|
||||
entity_id = None
|
||||
entity_name = None
|
||||
|
||||
# Look up area first to fail early
|
||||
area_slot = slots.get("area", {})
|
||||
@ -416,9 +425,7 @@ class ServiceIntentHandler(IntentHandler):
|
||||
area: area_registry.AreaEntry | None = None
|
||||
if area_id is not None:
|
||||
areas = area_registry.async_get(hass)
|
||||
area = areas.async_get_area(area_id) or areas.async_get_area_by_name(
|
||||
area_name
|
||||
)
|
||||
area = areas.async_get_area(area_id)
|
||||
if area is None:
|
||||
raise IntentHandleError(f"No area named {area_name}")
|
||||
|
||||
@ -436,7 +443,7 @@ class ServiceIntentHandler(IntentHandler):
|
||||
states = list(
|
||||
async_match_states(
|
||||
hass,
|
||||
name=entity_id,
|
||||
name=entity_name,
|
||||
area=area,
|
||||
domains=domains,
|
||||
device_classes=device_classes,
|
||||
@ -447,14 +454,24 @@ class ServiceIntentHandler(IntentHandler):
|
||||
if not states:
|
||||
# No states matched constraints
|
||||
raise NoStatesMatchedError(
|
||||
name=entity_name or entity_id,
|
||||
name=entity_text or entity_name,
|
||||
area=area_name or area_id,
|
||||
domains=domains,
|
||||
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)
|
||||
|
||||
# Make the matched states available in the response
|
||||
response.async_set_states(matched_states=states, unmatched_states=[])
|
||||
|
||||
return response
|
||||
|
||||
async def async_handle_states(
|
||||
|
@ -273,7 +273,13 @@ class _TranslationCache:
|
||||
for key, value in updated_resources.items():
|
||||
if key not in cached_resources:
|
||||
continue
|
||||
tuples = list(string.Formatter().parse(value))
|
||||
try:
|
||||
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}
|
||||
|
||||
tuples = list(string.Formatter().parse(cached_resources[key]))
|
||||
|
@ -28,7 +28,7 @@ habluetooth==2.4.0
|
||||
hass-nabucasa==0.76.0
|
||||
hassil==1.6.1
|
||||
home-assistant-bluetooth==1.12.0
|
||||
home-assistant-frontend==20240207.0
|
||||
home-assistant-frontend==20240207.1
|
||||
home-assistant-intents==2024.2.2
|
||||
httpx==0.26.0
|
||||
ifaddr==0.2.0
|
||||
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2024.2.0"
|
||||
version = "2024.2.1"
|
||||
license = {text = "Apache-2.0"}
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
readme = "README.rst"
|
||||
|
@ -76,7 +76,7 @@ PyMetEireann==2021.8.0
|
||||
PyMetno==0.11.0
|
||||
|
||||
# homeassistant.components.keymitt_ble
|
||||
PyMicroBot==0.0.10
|
||||
PyMicroBot==0.0.12
|
||||
|
||||
# homeassistant.components.nina
|
||||
PyNINA==0.3.3
|
||||
@ -173,7 +173,7 @@ aio-geojson-generic-client==0.4
|
||||
aio-geojson-geonetnz-quakes==0.16
|
||||
|
||||
# homeassistant.components.geonetnz_volcano
|
||||
aio-geojson-geonetnz-volcano==0.8
|
||||
aio-geojson-geonetnz-volcano==0.9
|
||||
|
||||
# homeassistant.components.nsw_rural_fire_service_feed
|
||||
aio-geojson-nsw-rfs-incidents==0.7
|
||||
@ -230,10 +230,10 @@ aioeafm==0.1.2
|
||||
aioeagle==1.1.0
|
||||
|
||||
# homeassistant.components.ecowitt
|
||||
aioecowitt==2024.2.0
|
||||
aioecowitt==2024.2.1
|
||||
|
||||
# homeassistant.components.co2signal
|
||||
aioelectricitymaps==0.3.1
|
||||
aioelectricitymaps==0.4.0
|
||||
|
||||
# homeassistant.components.emonitor
|
||||
aioemonitor==1.0.5
|
||||
@ -684,7 +684,7 @@ debugpy==1.8.0
|
||||
# decora==0.6
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
deebot-client==5.1.0
|
||||
deebot-client==5.1.1
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
@ -818,7 +818,7 @@ eufylife-ble-client==0.1.8
|
||||
# evdev==1.6.1
|
||||
|
||||
# homeassistant.components.evohome
|
||||
evohome-async==0.4.17
|
||||
evohome-async==0.4.18
|
||||
|
||||
# homeassistant.components.faa_delays
|
||||
faadelays==2023.9.1
|
||||
@ -1059,7 +1059,7 @@ hole==0.8.0
|
||||
holidays==0.42
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20240207.0
|
||||
home-assistant-frontend==20240207.1
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2024.2.2
|
||||
@ -1579,7 +1579,7 @@ pushover_complete==1.1.1
|
||||
pvo==2.1.1
|
||||
|
||||
# homeassistant.components.aosmith
|
||||
py-aosmith==1.0.6
|
||||
py-aosmith==1.0.8
|
||||
|
||||
# homeassistant.components.canary
|
||||
py-canary==0.5.3
|
||||
@ -2238,7 +2238,7 @@ python-kasa[speedups]==0.6.2.1
|
||||
# python-lirc==1.2.3
|
||||
|
||||
# homeassistant.components.matter
|
||||
python-matter-server==5.4.1
|
||||
python-matter-server==5.5.0
|
||||
|
||||
# homeassistant.components.xiaomi_miio
|
||||
python-miio==0.5.12
|
||||
|
@ -64,7 +64,7 @@ PyMetEireann==2021.8.0
|
||||
PyMetno==0.11.0
|
||||
|
||||
# homeassistant.components.keymitt_ble
|
||||
PyMicroBot==0.0.10
|
||||
PyMicroBot==0.0.12
|
||||
|
||||
# homeassistant.components.nina
|
||||
PyNINA==0.3.3
|
||||
@ -152,7 +152,7 @@ aio-geojson-generic-client==0.4
|
||||
aio-geojson-geonetnz-quakes==0.16
|
||||
|
||||
# homeassistant.components.geonetnz_volcano
|
||||
aio-geojson-geonetnz-volcano==0.8
|
||||
aio-geojson-geonetnz-volcano==0.9
|
||||
|
||||
# homeassistant.components.nsw_rural_fire_service_feed
|
||||
aio-geojson-nsw-rfs-incidents==0.7
|
||||
@ -209,10 +209,10 @@ aioeafm==0.1.2
|
||||
aioeagle==1.1.0
|
||||
|
||||
# homeassistant.components.ecowitt
|
||||
aioecowitt==2024.2.0
|
||||
aioecowitt==2024.2.1
|
||||
|
||||
# homeassistant.components.co2signal
|
||||
aioelectricitymaps==0.3.1
|
||||
aioelectricitymaps==0.4.0
|
||||
|
||||
# homeassistant.components.emonitor
|
||||
aioemonitor==1.0.5
|
||||
@ -559,7 +559,7 @@ dbus-fast==2.21.1
|
||||
debugpy==1.8.0
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
deebot-client==5.1.0
|
||||
deebot-client==5.1.1
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
@ -855,7 +855,7 @@ hole==0.8.0
|
||||
holidays==0.42
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20240207.0
|
||||
home-assistant-frontend==20240207.1
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2024.2.2
|
||||
@ -1232,7 +1232,7 @@ pushover_complete==1.1.1
|
||||
pvo==2.1.1
|
||||
|
||||
# homeassistant.components.aosmith
|
||||
py-aosmith==1.0.6
|
||||
py-aosmith==1.0.8
|
||||
|
||||
# homeassistant.components.canary
|
||||
py-canary==0.5.3
|
||||
@ -1711,7 +1711,7 @@ python-juicenet==1.1.0
|
||||
python-kasa[speedups]==0.6.2.1
|
||||
|
||||
# homeassistant.components.matter
|
||||
python-matter-server==5.4.1
|
||||
python-matter-server==5.5.0
|
||||
|
||||
# homeassistant.components.xiaomi_miio
|
||||
python-miio==0.5.12
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""Test climate intents."""
|
||||
|
||||
from collections.abc import Generator
|
||||
from unittest.mock import patch
|
||||
|
||||
@ -135,8 +136,10 @@ async def test_get_temperature(
|
||||
# Add climate entities to different areas:
|
||||
# climate_1 => living room
|
||||
# climate_2 => bedroom
|
||||
# nothing in office
|
||||
living_room_area = area_registry.async_create(name="Living Room")
|
||||
bedroom_area = area_registry.async_create(name="Bedroom")
|
||||
office_area = area_registry.async_create(name="Office")
|
||||
|
||||
entity_registry.async_update_entity(
|
||||
climate_1.entity_id, area_id=living_room_area.id
|
||||
@ -158,7 +161,7 @@ async def test_get_temperature(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{"area": {"value": "Bedroom"}},
|
||||
{"area": {"value": bedroom_area.name}},
|
||||
)
|
||||
assert response.response_type == intent.IntentResponseType.QUERY_ANSWER
|
||||
assert len(response.matched_states) == 1
|
||||
@ -179,6 +182,52 @@ async def test_get_temperature(
|
||||
state = response.matched_states[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(
|
||||
hass: HomeAssistant,
|
||||
@ -216,19 +265,28 @@ async def test_get_temperature_no_state(
|
||||
climate_1.entity_id, area_id=living_room_area.id
|
||||
)
|
||||
|
||||
with patch("homeassistant.core.StateMachine.get", return_value=None), pytest.raises(
|
||||
intent.IntentHandleError
|
||||
with (
|
||||
patch("homeassistant.core.StateMachine.get", return_value=None),
|
||||
pytest.raises(intent.IntentHandleError),
|
||||
):
|
||||
await intent.async_handle(
|
||||
hass, "test", climate_intent.INTENT_GET_TEMPERATURE, {}
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.core.StateMachine.async_all", return_value=[]
|
||||
), pytest.raises(intent.IntentHandleError):
|
||||
with (
|
||||
patch("homeassistant.core.StateMachine.async_all", return_value=[]),
|
||||
pytest.raises(intent.NoStatesMatchedError) as error,
|
||||
):
|
||||
await intent.async_handle(
|
||||
hass,
|
||||
"test",
|
||||
climate_intent.INTENT_GET_TEMPERATURE,
|
||||
{"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
|
||||
|
@ -1397,7 +1397,7 @@
|
||||
'name': dict({
|
||||
'name': 'name',
|
||||
'text': 'my cool light',
|
||||
'value': 'light.kitchen',
|
||||
'value': 'my cool light',
|
||||
}),
|
||||
}),
|
||||
'intent': dict({
|
||||
@ -1422,7 +1422,7 @@
|
||||
'name': dict({
|
||||
'name': 'name',
|
||||
'text': 'my cool light',
|
||||
'value': 'light.kitchen',
|
||||
'value': 'my cool light',
|
||||
}),
|
||||
}),
|
||||
'intent': dict({
|
||||
@ -1572,7 +1572,7 @@
|
||||
'name': dict({
|
||||
'name': 'name',
|
||||
'text': 'test light',
|
||||
'value': 'light.demo_1234',
|
||||
'value': 'test light',
|
||||
}),
|
||||
}),
|
||||
'intent': dict({
|
||||
@ -1604,7 +1604,7 @@
|
||||
'name': dict({
|
||||
'name': 'name',
|
||||
'text': 'test light',
|
||||
'value': 'light.demo_1234',
|
||||
'value': 'test light',
|
||||
}),
|
||||
}),
|
||||
'intent': dict({
|
||||
|
@ -101,7 +101,7 @@ async def test_exposed_areas(
|
||||
device_registry.async_update_device(kitchen_device.id, area_id=area_kitchen.id)
|
||||
|
||||
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
|
||||
)
|
||||
hass.states.async_set(
|
||||
@ -109,7 +109,7 @@ async def test_exposed_areas(
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
hass.states.async_set(
|
||||
@ -206,14 +206,14 @@ async def test_unexposed_entities_skipped(
|
||||
|
||||
# Both lights are in the kitchen
|
||||
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,
|
||||
area_id=area_kitchen.id,
|
||||
)
|
||||
hass.states.async_set(exposed_light.entity_id, "off")
|
||||
|
||||
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,
|
||||
area_id=area_kitchen.id,
|
||||
)
|
||||
@ -336,7 +336,9 @@ async def test_device_area_context(
|
||||
light_entity = entity_registry.async_get_or_create(
|
||||
"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(
|
||||
light_entity.entity_id,
|
||||
"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(
|
||||
hass: HomeAssistant, init_components, area_registry: ar.AreaRegistry
|
||||
) -> None:
|
||||
@ -692,7 +803,7 @@ async def test_empty_aliases(
|
||||
|
||||
names = slot_lists["name"]
|
||||
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
|
||||
|
||||
|
||||
@ -713,3 +824,191 @@ async def test_all_domains_loaded(hass: HomeAssistant, init_components) -> None:
|
||||
result.response.speech["plain"]["speech"]
|
||||
== "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
|
||||
|
@ -1,4 +1,7 @@
|
||||
"""Test conversation triggers."""
|
||||
|
||||
import logging
|
||||
|
||||
import pytest
|
||||
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:
|
||||
"""Test the firing of events."""
|
||||
"""Test the conversation response action."""
|
||||
response = "I'm sorry, Dave. I'm afraid I can't do that"
|
||||
assert await async_setup_component(
|
||||
hass,
|
||||
@ -100,6 +103,116 @@ async def test_response(hass: HomeAssistant, setup_comp) -> None:
|
||||
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(
|
||||
hass: HomeAssistant, setup_comp, hass_ws_client: WebSocketGenerator
|
||||
) -> None:
|
||||
|
@ -1,7 +1,9 @@
|
||||
"""Test the Emulated Hue component."""
|
||||
from datetime import timedelta
|
||||
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 (
|
||||
DATA_KEY,
|
||||
@ -135,6 +137,9 @@ async def test_setup_works(hass: HomeAssistant) -> None:
|
||||
AsyncMock(),
|
||||
) as mock_create_upnp_datagram_endpoint, patch(
|
||||
"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(
|
||||
spec=UPNPResponderProtocol
|
||||
|
@ -293,7 +293,7 @@ async def test_setup_api_push_api_data(
|
||||
assert aioclient_mock.call_count == 19
|
||||
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]["watchdog"]
|
||||
assert "watchdog" not in aioclient_mock.mock_calls[1][2]
|
||||
|
||||
|
||||
async def test_setup_api_push_api_data_server_host(
|
||||
|
@ -1,4 +1,5 @@
|
||||
"""Tests for Intent component."""
|
||||
|
||||
import pytest
|
||||
|
||||
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"]}
|
||||
|
||||
|
||||
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(
|
||||
hass: HomeAssistant,
|
||||
area_registry: ar.AreaRegistry,
|
||||
|
@ -144,10 +144,10 @@ async def test_node_added_subscription(
|
||||
integration: MagicMock,
|
||||
) -> None:
|
||||
"""Test subscription to new devices work."""
|
||||
assert matter_client.subscribe_events.call_count == 4
|
||||
assert matter_client.subscribe_events.call_count == 5
|
||||
assert (
|
||||
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"]
|
||||
|
@ -229,6 +229,7 @@ async def test_node_diagnostics(
|
||||
mac_address="00:11:22:33:44:55",
|
||||
available=True,
|
||||
active_fabrics=[MatterFabricData(2, 4939, 1, vendor_name="Nabu Casa")],
|
||||
active_fabric_index=0,
|
||||
)
|
||||
matter_client.node_diagnostics = AsyncMock(return_value=mock_diagnostics)
|
||||
|
||||
|
@ -42,6 +42,8 @@ from homeassistant.components.modbus.const import (
|
||||
CONF_HVAC_MODE_REGISTER,
|
||||
CONF_HVAC_MODE_VALUES,
|
||||
CONF_HVAC_ONOFF_REGISTER,
|
||||
CONF_MAX_TEMP,
|
||||
CONF_MIN_TEMP,
|
||||
CONF_TARGET_TEMP,
|
||||
CONF_TARGET_TEMP_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:
|
||||
|
@ -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:
|
||||
@ -688,6 +710,16 @@ async def test_config_wrong_struct_sensor(
|
||||
False,
|
||||
"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:
|
||||
|
@ -4257,3 +4257,64 @@ async def test_update_entry_and_reload(
|
||||
assert entry.state == config_entries.ConfigEntryState.LOADED
|
||||
assert task["type"] == FlowResultType.ABORT
|
||||
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
|
||||
|
Loading…
x
Reference in New Issue
Block a user