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,
"documentation": "https://www.home-assistant.io/integrations/aosmith",
"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."""
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:

View File

@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"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
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):

View File

@ -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"]
}

View File

@ -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"]
}

View File

@ -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"]
}

View File

@ -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"]
}

View File

@ -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"]
}

View File

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

View File

@ -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

View File

@ -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

View File

@ -16,5 +16,5 @@
"integration_type": "hub",
"iot_class": "assumed_state",
"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:
"""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."""

View File

@ -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,

View File

@ -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"]
}

View File

@ -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,
}

View File

@ -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

View File

@ -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")

View File

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

View File

@ -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(

View File

@ -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]))

View File

@ -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

View File

@ -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"

View File

@ -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

View File

@ -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

View File

@ -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

View File

@ -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({

View File

@ -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

View File

@ -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:

View File

@ -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

View File

@ -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(

View File

@ -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,

View File

@ -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"]

View File

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

View File

@ -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:

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:
@ -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:

View File

@ -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