forked from home-assistant/core
Compare commits
67 Commits
2024.2.0b1
...
2024.2.2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7aa14e20d1 | ||
|
|
b55b2c8da3 | ||
|
|
8c05ebd031 | ||
|
|
34a3e88e0d | ||
|
|
bf002ac0b0 | ||
|
|
6f529a2c77 | ||
|
|
e5db7278e1 | ||
|
|
cdf67e9bb5 | ||
|
|
393359a546 | ||
|
|
9309e38302 | ||
|
|
479ecc8b94 | ||
|
|
ec7950aeda | ||
|
|
c763483049 | ||
|
|
fe84e7a576 | ||
|
|
5ba31290b8 | ||
|
|
de619e4ddc | ||
|
|
56ceadaeeb | ||
|
|
da61564f82 | ||
|
|
003673cd29 | ||
|
|
da6c571e65 | ||
|
|
159fab7025 | ||
|
|
96a10e76b8 | ||
|
|
e7068ae134 | ||
|
|
6a0c3f1b4f | ||
|
|
a0ae18a1b6 | ||
|
|
ad761bb2de | ||
|
|
edb69fb095 | ||
|
|
58b28e6df1 | ||
|
|
973a13abfa | ||
|
|
2a51377cef | ||
|
|
87bd67656b | ||
|
|
c79bc17d17 | ||
|
|
54270df217 | ||
|
|
5a87cde71e | ||
|
|
e825bcc282 | ||
|
|
b54a3170f0 | ||
|
|
349d8f5c28 | ||
|
|
cfd1f7809f | ||
|
|
5f9cc2fec1 | ||
|
|
58d46f6dec | ||
|
|
74ea9e24df | ||
|
|
437a2a829f | ||
|
|
f5884c6279 | ||
|
|
e4382a494c | ||
|
|
56ff767969 | ||
|
|
4a18f592c6 | ||
|
|
7ff2f376d4 | ||
|
|
a18918bb73 | ||
|
|
49e5709826 | ||
|
|
c665903f9d | ||
|
|
de44af2948 | ||
|
|
95a800b6bc | ||
|
|
a9e9ec2c3d | ||
|
|
7309c3c290 | ||
|
|
f48d70654b | ||
|
|
a9b3c2e2b5 | ||
|
|
19349e1779 | ||
|
|
e320d715c7 | ||
|
|
44c9ea68eb | ||
|
|
dbfee24eb7 | ||
|
|
3b7271d597 | ||
|
|
9dbf84228e | ||
|
|
9e47d03086 | ||
|
|
f63aaf8b5a | ||
|
|
8375fc235d | ||
|
|
3030870de0 | ||
|
|
f61c70b686 |
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -249,10 +249,11 @@ class AugustData(AugustSubscriberMixin):
|
||||
device = self.get_device_detail(device_id)
|
||||
activities = activities_from_pubnub_message(device, date_time, message)
|
||||
activity_stream = self.activity_stream
|
||||
if activities:
|
||||
activity_stream.async_process_newer_device_activities(activities)
|
||||
if activities and activity_stream.async_process_newer_device_activities(
|
||||
activities
|
||||
):
|
||||
self.async_signal_device_id_update(device.device_id)
|
||||
activity_stream.async_schedule_house_id_refresh(device.house_id)
|
||||
activity_stream.async_schedule_house_id_refresh(device.house_id)
|
||||
|
||||
@callback
|
||||
def async_stop(self) -> None:
|
||||
|
||||
@@ -28,5 +28,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==1.10.0", "yalexs-ble==2.4.1"]
|
||||
"requirements": ["yalexs==1.11.2", "yalexs-ble==2.4.1"]
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -8,6 +8,7 @@ from aioelectricitymaps import (
|
||||
ElectricityMaps,
|
||||
ElectricityMapsError,
|
||||
ElectricityMapsInvalidTokenError,
|
||||
ElectricityMapsNoDataError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -151,6 +152,8 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
await fetch_latest_carbon_intensity(self.hass, em, data)
|
||||
except ElectricityMapsInvalidTokenError:
|
||||
errors["base"] = "invalid_auth"
|
||||
except ElectricityMapsNoDataError:
|
||||
errors["base"] = "no_data"
|
||||
except ElectricityMapsError:
|
||||
errors["base"] = "unknown"
|
||||
else:
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioelectricitymaps"],
|
||||
"requirements": ["aioelectricitymaps==0.3.1"]
|
||||
"requirements": ["aioelectricitymaps==0.4.0"]
|
||||
}
|
||||
|
||||
@@ -28,12 +28,9 @@
|
||||
"error": {
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"api_ratelimit": "API Ratelimit exceeded"
|
||||
"no_data": "No data is available for the location you have selected."
|
||||
},
|
||||
"abort": {
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_device%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"api_ratelimit": "[%key:component::co2signal::config::error::api_ratelimit%]",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dmr",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["async_upnp_client"],
|
||||
"requirements": ["async-upnp-client==0.38.1", "getmac==0.9.4"],
|
||||
"requirements": ["async-upnp-client==0.38.2", "getmac==0.9.4"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaRenderer:1",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/dlna_dms",
|
||||
"iot_class": "local_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["async-upnp-client==0.38.1"],
|
||||
"requirements": ["async-upnp-client==0.38.2"],
|
||||
"ssdp": [
|
||||
{
|
||||
"deviceType": "urn:schemas-upnp-org:device:MediaServer:1",
|
||||
|
||||
@@ -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.9", "deebot-client==5.2.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"]
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ from types import MappingProxyType
|
||||
from typing import Any
|
||||
|
||||
from elkm1_lib.elements import Element
|
||||
from elkm1_lib.elk import Elk
|
||||
from elkm1_lib.elk import Elk, Panel
|
||||
from elkm1_lib.util import parse_url
|
||||
import voluptuous as vol
|
||||
|
||||
@@ -398,22 +398,30 @@ async def async_wait_for_elk_to_sync(
|
||||
return success
|
||||
|
||||
|
||||
@callback
|
||||
def _async_get_elk_panel(hass: HomeAssistant, service: ServiceCall) -> Panel:
|
||||
"""Get the ElkM1 panel from a service call."""
|
||||
prefix = service.data["prefix"]
|
||||
elk = _find_elk_by_prefix(hass, prefix)
|
||||
if elk is None:
|
||||
raise HomeAssistantError(f"No ElkM1 with prefix '{prefix}' found")
|
||||
return elk.panel
|
||||
|
||||
|
||||
def _create_elk_services(hass: HomeAssistant) -> None:
|
||||
def _getelk(service: ServiceCall) -> Elk:
|
||||
prefix = service.data["prefix"]
|
||||
elk = _find_elk_by_prefix(hass, prefix)
|
||||
if elk is None:
|
||||
raise HomeAssistantError(f"No ElkM1 with prefix '{prefix}' found")
|
||||
return elk
|
||||
"""Create ElkM1 services."""
|
||||
|
||||
@callback
|
||||
def _speak_word_service(service: ServiceCall) -> None:
|
||||
_getelk(service).panel.speak_word(service.data["number"])
|
||||
_async_get_elk_panel(hass, service).speak_word(service.data["number"])
|
||||
|
||||
@callback
|
||||
def _speak_phrase_service(service: ServiceCall) -> None:
|
||||
_getelk(service).panel.speak_phrase(service.data["number"])
|
||||
_async_get_elk_panel(hass, service).speak_phrase(service.data["number"])
|
||||
|
||||
@callback
|
||||
def _set_time_service(service: ServiceCall) -> None:
|
||||
_getelk(service).panel.set_time(dt_util.now())
|
||||
_async_get_elk_panel(hass, service).set_time(dt_util.now())
|
||||
|
||||
hass.services.async_register(
|
||||
DOMAIN, "speak_word", _speak_word_service, SPEAK_SERVICE_SCHEMA
|
||||
|
||||
@@ -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.19"]
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@ from homeassistant.const import CONF_HOST, CONF_PORT
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import DOMAIN
|
||||
from .router import get_api
|
||||
from .router import get_api, get_hosts_list_if_supported
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
@@ -69,7 +69,7 @@ class FreeboxFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
# Check permissions
|
||||
await fbx.system.get_config()
|
||||
await fbx.lan.get_hosts_list()
|
||||
await get_hosts_list_if_supported(fbx)
|
||||
|
||||
# Close connection
|
||||
await fbx.close()
|
||||
|
||||
@@ -64,6 +64,33 @@ async def get_api(hass: HomeAssistant, host: str) -> Freepybox:
|
||||
return Freepybox(APP_DESC, token_file, API_VERSION)
|
||||
|
||||
|
||||
async def get_hosts_list_if_supported(
|
||||
fbx_api: Freepybox,
|
||||
) -> tuple[bool, list[dict[str, Any]]]:
|
||||
"""Hosts list is not supported when freebox is configured in bridge mode."""
|
||||
supports_hosts: bool = True
|
||||
fbx_devices: list[dict[str, Any]] = []
|
||||
try:
|
||||
fbx_devices = await fbx_api.lan.get_hosts_list() or []
|
||||
except HttpRequestError as err:
|
||||
if (
|
||||
(matcher := re.search(r"Request failed \(APIResponse: (.+)\)", str(err)))
|
||||
and is_json(json_str := matcher.group(1))
|
||||
and (json_resp := json.loads(json_str)).get("error_code") == "nodev"
|
||||
):
|
||||
# No need to retry, Host list not available
|
||||
supports_hosts = False
|
||||
_LOGGER.debug(
|
||||
"Host list is not available using bridge mode (%s)",
|
||||
json_resp.get("msg"),
|
||||
)
|
||||
|
||||
else:
|
||||
raise err
|
||||
|
||||
return supports_hosts, fbx_devices
|
||||
|
||||
|
||||
class FreeboxRouter:
|
||||
"""Representation of a Freebox router."""
|
||||
|
||||
@@ -111,27 +138,9 @@ class FreeboxRouter:
|
||||
|
||||
# Access to Host list not available in bridge mode, API return error_code 'nodev'
|
||||
if self.supports_hosts:
|
||||
try:
|
||||
fbx_devices = await self._api.lan.get_hosts_list()
|
||||
except HttpRequestError as err:
|
||||
if (
|
||||
(
|
||||
matcher := re.search(
|
||||
r"Request failed \(APIResponse: (.+)\)", str(err)
|
||||
)
|
||||
)
|
||||
and is_json(json_str := matcher.group(1))
|
||||
and (json_resp := json.loads(json_str)).get("error_code") == "nodev"
|
||||
):
|
||||
# No need to retry, Host list not available
|
||||
self.supports_hosts = False
|
||||
_LOGGER.debug(
|
||||
"Host list is not available using bridge mode (%s)",
|
||||
json_resp.get("msg"),
|
||||
)
|
||||
|
||||
else:
|
||||
raise err
|
||||
self.supports_hosts, fbx_devices = await get_hosts_list_if_supported(
|
||||
self._api
|
||||
)
|
||||
|
||||
# Adds the Freebox itself
|
||||
fbx_devices.append(
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aio_geojson_geonetnz_quakes"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aio-geojson-geonetnz-quakes==0.15"]
|
||||
"requirements": ["aio-geojson-geonetnz-quakes==0.16"]
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -476,7 +476,7 @@ class SensorGroup(GroupEntity, SensorEntity):
|
||||
translation_placeholders={
|
||||
"entity_id": self.entity_id,
|
||||
"source_entities": ", ".join(self._entity_ids),
|
||||
"state_classes:": ", ".join(state_classes),
|
||||
"state_classes": ", ".join(state_classes),
|
||||
},
|
||||
)
|
||||
return None
|
||||
@@ -519,7 +519,7 @@ class SensorGroup(GroupEntity, SensorEntity):
|
||||
translation_placeholders={
|
||||
"entity_id": self.entity_id,
|
||||
"source_entities": ", ".join(self._entity_ids),
|
||||
"device_classes:": ", ".join(device_classes),
|
||||
"device_classes": ", ".join(device_classes),
|
||||
},
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -265,7 +265,7 @@
|
||||
},
|
||||
"state_classes_not_matching": {
|
||||
"title": "State classes is not correct",
|
||||
"description": "Device classes `{state_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the state classes on the source entities and reload the group sensor to fix this issue."
|
||||
"description": "State classes `{state_classes}` on source entities `{source_entities}` needs to be same for sensor group `{entity_id}`.\n\nPlease correct the state classes on the source entities and reload the group sensor to fix this issue."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
"quality_scale": "platinum",
|
||||
"requirements": [
|
||||
"xknx==2.12.0",
|
||||
"xknxproject==3.5.0",
|
||||
"xknxproject==3.6.0",
|
||||
"knx-frontend==2024.1.20.105944"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -6,11 +6,12 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
from linear_garage_door import Linear
|
||||
from linear_garage_door.errors import InvalidLoginError, ResponseError
|
||||
from linear_garage_door.errors import InvalidLoginError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers.aiohttp_client import async_get_clientsession
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
@@ -55,6 +56,7 @@ class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
email=self._email,
|
||||
password=self._password,
|
||||
device_id=self._device_id,
|
||||
client_session=async_get_clientsession(self.hass),
|
||||
)
|
||||
except InvalidLoginError as err:
|
||||
if (
|
||||
@@ -63,8 +65,6 @@ class LinearUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
|
||||
):
|
||||
raise ConfigEntryAuthFailed from err
|
||||
raise ConfigEntryNotReady from err
|
||||
except ResponseError as err:
|
||||
raise ConfigEntryNotReady from err
|
||||
|
||||
if not self._devices:
|
||||
self._devices = await linear.get_devices(self._site_id)
|
||||
|
||||
@@ -5,5 +5,5 @@
|
||||
"config_flow": true,
|
||||
"documentation": "https://www.home-assistant.io/integrations/linear_garage_door",
|
||||
"iot_class": "cloud_polling",
|
||||
"requirements": ["linear-garage-door==0.2.7"]
|
||||
"requirements": ["linear-garage-door==0.2.9"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/lutron",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["pylutron"],
|
||||
"requirements": ["pylutron==0.2.8"]
|
||||
"requirements": ["pylutron==0.2.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"]
|
||||
}
|
||||
|
||||
@@ -4,9 +4,10 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
import datapoint
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_API_KEY,
|
||||
@@ -16,7 +17,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.device_registry import DeviceInfo
|
||||
from homeassistant.helpers.update_coordinator import TimestampDataUpdateCoordinator
|
||||
@@ -34,9 +35,6 @@ from .const import (
|
||||
from .data import MetOfficeData
|
||||
from .helpers import fetch_data, fetch_site
|
||||
|
||||
if sys.version_info < (3, 12):
|
||||
import datapoint
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
|
||||
@@ -44,10 +42,6 @@ PLATFORMS = [Platform.SENSOR, Platform.WEATHER]
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up a Met Office entry."""
|
||||
if sys.version_info >= (3, 12):
|
||||
raise HomeAssistantError(
|
||||
"Met Office is not supported on Python 3.12. Please use Python 3.11."
|
||||
)
|
||||
|
||||
latitude = entry.data[CONF_LATITUDE]
|
||||
longitude = entry.data[CONF_LONGITUDE]
|
||||
|
||||
@@ -2,12 +2,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
import sys
|
||||
|
||||
if sys.version_info < (3, 12):
|
||||
from datapoint.Forecast import Forecast
|
||||
from datapoint.Site import Site
|
||||
from datapoint.Timestep import Timestep
|
||||
from datapoint.Forecast import Forecast
|
||||
from datapoint.Site import Site
|
||||
from datapoint.Timestep import Timestep
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
|
||||
import datapoint
|
||||
from datapoint.Site import Site
|
||||
|
||||
from homeassistant.helpers.update_coordinator import UpdateFailed
|
||||
from homeassistant.util.dt import utcnow
|
||||
@@ -10,11 +12,6 @@ from homeassistant.util.dt import utcnow
|
||||
from .const import MODE_3HOURLY
|
||||
from .data import MetOfficeData
|
||||
|
||||
if sys.version_info < (3, 12):
|
||||
import datapoint
|
||||
from datapoint.Site import Site
|
||||
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -34,7 +31,7 @@ def fetch_site(
|
||||
def fetch_data(connection: datapoint.Manager, site: Site, mode: str) -> MetOfficeData:
|
||||
"""Fetch weather and forecast from Datapoint API."""
|
||||
try:
|
||||
forecast = connection.get_forecast_for_site(site.id, mode)
|
||||
forecast = connection.get_forecast_for_site(site.location_id, mode)
|
||||
except (ValueError, datapoint.exceptions.APIException) as err:
|
||||
_LOGGER.error("Check Met Office connection: %s", err.args)
|
||||
raise UpdateFailed from err
|
||||
|
||||
@@ -3,9 +3,8 @@
|
||||
"name": "Met Office",
|
||||
"codeowners": ["@MrHarcombe", "@avee87"],
|
||||
"config_flow": true,
|
||||
"disabled": "Integration library not compatible with Python 3.12",
|
||||
"documentation": "https://www.home-assistant.io/integrations/metoffice",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["datapoint"],
|
||||
"requirements": ["datapoint==0.9.8;python_version<'3.12'"]
|
||||
"requirements": ["datapoint==0.9.9"]
|
||||
}
|
||||
|
||||
@@ -251,6 +251,6 @@ class MetOfficeCurrentSensor(
|
||||
return {
|
||||
ATTR_LAST_UPDATE: self.coordinator.data.now.date,
|
||||
ATTR_SENSOR_ID: self.entity_description.key,
|
||||
ATTR_SITE_ID: self.coordinator.data.site.id,
|
||||
ATTR_SITE_ID: self.coordinator.data.site.location_id,
|
||||
ATTR_SITE_NAME: self.coordinator.data.site.name,
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -199,6 +199,8 @@ class BaseStructPlatform(BasePlatform, RestoreEntity):
|
||||
self._precision = config.get(CONF_PRECISION, 2)
|
||||
else:
|
||||
self._precision = config.get(CONF_PRECISION, 0)
|
||||
if self._precision > 0 or self._scale != int(self._scale):
|
||||
self._value_is_int = False
|
||||
|
||||
def _swap_registers(self, registers: list[int], slave_count: int) -> list[int]:
|
||||
"""Do swap as needed."""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -400,6 +400,7 @@ class MotionTDBUDevice(MotionPositionDevice):
|
||||
def __init__(self, coordinator, blind, device_class, motor):
|
||||
"""Initialize the blind."""
|
||||
super().__init__(coordinator, blind, device_class)
|
||||
delattr(self, "_attr_name")
|
||||
self._motor = motor
|
||||
self._motor_key = motor[0]
|
||||
self._attr_translation_key = motor.lower()
|
||||
|
||||
@@ -75,7 +75,7 @@ class MyUplinkDevicePointSensor(MyUplinkEntity, SensorEntity):
|
||||
|
||||
# Internal properties
|
||||
self.point_id = device_point.parameter_id
|
||||
self._attr_name = device_point.parameter_name
|
||||
self._attr_name = device_point.parameter_name.replace("\u002d", "")
|
||||
|
||||
if entity_description is not None:
|
||||
self.entity_description = entity_description
|
||||
|
||||
@@ -212,7 +212,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
host = entry.data[CONF_HOST]
|
||||
password = entry.data[CONF_PASSWORD]
|
||||
|
||||
if DOMAIN not in hass.data:
|
||||
if not (data := hass.data.get(DOMAIN)) or data.websession.closed:
|
||||
websession = async_create_clientsession(hass, cookie_jar=CookieJar(unsafe=True))
|
||||
|
||||
hass.data[DOMAIN] = LTEData(websession)
|
||||
@@ -258,7 +258,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
if entry.state == ConfigEntryState.LOADED
|
||||
]
|
||||
if len(loaded_entries) == 1:
|
||||
hass.data.pop(DOMAIN)
|
||||
hass.data.pop(DOMAIN, None)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aiopegelonline"],
|
||||
"requirements": ["aiopegelonline==0.0.6"]
|
||||
"requirements": ["aiopegelonline==0.0.8"]
|
||||
}
|
||||
|
||||
@@ -21,7 +21,10 @@ from homeassistant.components.climate import (
|
||||
ATTR_TARGET_TEMP_LOW,
|
||||
HVACAction,
|
||||
)
|
||||
from homeassistant.components.cover import ATTR_POSITION, ATTR_TILT_POSITION
|
||||
from homeassistant.components.cover import (
|
||||
ATTR_CURRENT_POSITION,
|
||||
ATTR_CURRENT_TILT_POSITION,
|
||||
)
|
||||
from homeassistant.components.http import HomeAssistantView
|
||||
from homeassistant.components.humidifier import ATTR_AVAILABLE_MODES, ATTR_HUMIDITY
|
||||
from homeassistant.components.light import ATTR_BRIGHTNESS
|
||||
@@ -437,7 +440,7 @@ class PrometheusMetrics:
|
||||
float(cover_state == state.state)
|
||||
)
|
||||
|
||||
position = state.attributes.get(ATTR_POSITION)
|
||||
position = state.attributes.get(ATTR_CURRENT_POSITION)
|
||||
if position is not None:
|
||||
position_metric = self._metric(
|
||||
"cover_position",
|
||||
@@ -446,7 +449,7 @@ class PrometheusMetrics:
|
||||
)
|
||||
position_metric.labels(**self._labels(state)).set(float(position))
|
||||
|
||||
tilt_position = state.attributes.get(ATTR_TILT_POSITION)
|
||||
tilt_position = state.attributes.get(ATTR_CURRENT_TILT_POSITION)
|
||||
if tilt_position is not None:
|
||||
tilt_position_metric = self._metric(
|
||||
"cover_tilt_position",
|
||||
|
||||
@@ -115,6 +115,7 @@ async def setup_device(
|
||||
device.name,
|
||||
)
|
||||
_LOGGER.debug(err)
|
||||
await mqtt_client.async_release()
|
||||
raise err
|
||||
coordinator = RoborockDataUpdateCoordinator(
|
||||
hass, device, networking, product_info, mqtt_client
|
||||
@@ -125,6 +126,7 @@ async def setup_device(
|
||||
try:
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
except ConfigEntryNotReady:
|
||||
await coordinator.release()
|
||||
if isinstance(coordinator.api, RoborockMqttClient):
|
||||
_LOGGER.warning(
|
||||
"Not setting up %s because the we failed to get data for the first time using the online client. "
|
||||
@@ -153,14 +155,10 @@ async def setup_device(
|
||||
|
||||
async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Handle removal of an entry."""
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
await asyncio.gather(
|
||||
*(
|
||||
coordinator.release()
|
||||
for coordinator in hass.data[DOMAIN][entry.entry_id].values()
|
||||
)
|
||||
)
|
||||
if unload_ok := await hass.config_entries.async_unload_platforms(entry, PLATFORMS):
|
||||
release_tasks = set()
|
||||
for coordinator in hass.data[DOMAIN][entry.entry_id].values():
|
||||
release_tasks.add(coordinator.release())
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
await asyncio.gather(*release_tasks)
|
||||
return unload_ok
|
||||
|
||||
@@ -77,7 +77,8 @@ class RoborockDataUpdateCoordinator(DataUpdateCoordinator[DeviceProp]):
|
||||
|
||||
async def release(self) -> None:
|
||||
"""Disconnect from API."""
|
||||
await self.api.async_disconnect()
|
||||
await self.api.async_release()
|
||||
await self.cloud_api.async_release()
|
||||
|
||||
async def _update_device_prop(self) -> None:
|
||||
"""Update device properties."""
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
"""Support for Roborock device base class."""
|
||||
|
||||
from typing import Any
|
||||
|
||||
from roborock.api import AttributeCache, RoborockClient
|
||||
@@ -7,6 +6,7 @@ from roborock.cloud_api import RoborockMqttClient
|
||||
from roborock.command_cache import CacheableAttribute
|
||||
from roborock.containers import Consumable, Status
|
||||
from roborock.exceptions import RoborockException
|
||||
from roborock.roborock_message import RoborockDataProtocol
|
||||
from roborock.roborock_typing import RoborockCommand
|
||||
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
@@ -24,7 +24,10 @@ class RoborockEntity(Entity):
|
||||
_attr_has_entity_name = True
|
||||
|
||||
def __init__(
|
||||
self, unique_id: str, device_info: DeviceInfo, api: RoborockClient
|
||||
self,
|
||||
unique_id: str,
|
||||
device_info: DeviceInfo,
|
||||
api: RoborockClient,
|
||||
) -> None:
|
||||
"""Initialize the coordinated Roborock Device."""
|
||||
self._attr_unique_id = unique_id
|
||||
@@ -75,6 +78,9 @@ class RoborockCoordinatedEntity(
|
||||
self,
|
||||
unique_id: str,
|
||||
coordinator: RoborockDataUpdateCoordinator,
|
||||
listener_request: list[RoborockDataProtocol]
|
||||
| RoborockDataProtocol
|
||||
| None = None,
|
||||
) -> None:
|
||||
"""Initialize the coordinated Roborock Device."""
|
||||
RoborockEntity.__init__(
|
||||
@@ -85,6 +91,23 @@ class RoborockCoordinatedEntity(
|
||||
)
|
||||
CoordinatorEntity.__init__(self, coordinator=coordinator)
|
||||
self._attr_unique_id = unique_id
|
||||
if isinstance(listener_request, RoborockDataProtocol):
|
||||
listener_request = [listener_request]
|
||||
self.listener_requests = listener_request or []
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Add listeners when the device is added to hass."""
|
||||
await super().async_added_to_hass()
|
||||
for listener_request in self.listener_requests:
|
||||
self.api.add_listener(
|
||||
listener_request, self._update_from_listener, cache=self.api.cache
|
||||
)
|
||||
|
||||
async def async_will_remove_from_hass(self) -> None:
|
||||
"""Remove listeners when the device is removed from hass."""
|
||||
for listener_request in self.listener_requests:
|
||||
self.api.remove_listener(listener_request, self._update_from_listener)
|
||||
await super().async_will_remove_from_hass()
|
||||
|
||||
@property
|
||||
def _device_status(self) -> Status:
|
||||
@@ -107,7 +130,7 @@ class RoborockCoordinatedEntity(
|
||||
await self.coordinator.async_refresh()
|
||||
return res
|
||||
|
||||
def _update_from_listener(self, value: Status | Consumable):
|
||||
def _update_from_listener(self, value: Status | Consumable) -> None:
|
||||
"""Update the status or consumable data from a listener and then write the new entity state."""
|
||||
if isinstance(value, Status):
|
||||
self.coordinator.roborock_device_info.props.status = value
|
||||
|
||||
@@ -107,10 +107,8 @@ class RoborockSelectEntity(RoborockCoordinatedEntity, SelectEntity):
|
||||
) -> None:
|
||||
"""Create a select entity."""
|
||||
self.entity_description = entity_description
|
||||
super().__init__(unique_id, coordinator)
|
||||
super().__init__(unique_id, coordinator, entity_description.protocol_listener)
|
||||
self._attr_options = options
|
||||
if (protocol := self.entity_description.protocol_listener) is not None:
|
||||
self.api.add_listener(protocol, self._update_from_listener, self.api.cache)
|
||||
|
||||
async def async_select_option(self, option: str) -> None:
|
||||
"""Set the option."""
|
||||
|
||||
@@ -232,10 +232,8 @@ class RoborockSensorEntity(RoborockCoordinatedEntity, SensorEntity):
|
||||
description: RoborockSensorDescription,
|
||||
) -> None:
|
||||
"""Initialize the entity."""
|
||||
super().__init__(unique_id, coordinator)
|
||||
self.entity_description = description
|
||||
if (protocol := self.entity_description.protocol_listener) is not None:
|
||||
self.api.add_listener(protocol, self._update_from_listener, self.api.cache)
|
||||
super().__init__(unique_id, coordinator, description.protocol_listener)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime.datetime:
|
||||
|
||||
@@ -92,14 +92,16 @@ class RoborockVacuum(RoborockCoordinatedEntity, StateVacuumEntity):
|
||||
) -> None:
|
||||
"""Initialize a vacuum."""
|
||||
StateVacuumEntity.__init__(self)
|
||||
RoborockCoordinatedEntity.__init__(self, unique_id, coordinator)
|
||||
RoborockCoordinatedEntity.__init__(
|
||||
self,
|
||||
unique_id,
|
||||
coordinator,
|
||||
listener_request=[
|
||||
RoborockDataProtocol.FAN_POWER,
|
||||
RoborockDataProtocol.STATE,
|
||||
],
|
||||
)
|
||||
self._attr_fan_speed_list = self._device_status.fan_power_options
|
||||
self.api.add_listener(
|
||||
RoborockDataProtocol.FAN_POWER, self._update_from_listener, self.api.cache
|
||||
)
|
||||
self.api.add_listener(
|
||||
RoborockDataProtocol.STATE, self._update_from_listener, self.api.cache
|
||||
)
|
||||
|
||||
@property
|
||||
def state(self) -> str | None:
|
||||
|
||||
@@ -39,7 +39,7 @@
|
||||
"samsungctl[websocket]==0.7.1",
|
||||
"samsungtvws[async,encrypted]==2.6.0",
|
||||
"wakeonlan==2.1.0",
|
||||
"async-upnp-client==0.38.1"
|
||||
"async-upnp-client==0.38.2"
|
||||
],
|
||||
"ssdp": [
|
||||
{
|
||||
|
||||
@@ -173,9 +173,9 @@ async def async_setup_entry(
|
||||
platform.async_register_entity_service(
|
||||
SERVICE_ENABLE_CLIMATE_REACT,
|
||||
{
|
||||
vol.Required(ATTR_HIGH_TEMPERATURE_THRESHOLD): float,
|
||||
vol.Required(ATTR_HIGH_TEMPERATURE_THRESHOLD): vol.Coerce(float),
|
||||
vol.Required(ATTR_HIGH_TEMPERATURE_STATE): dict,
|
||||
vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): float,
|
||||
vol.Required(ATTR_LOW_TEMPERATURE_THRESHOLD): vol.Coerce(float),
|
||||
vol.Required(ATTR_LOW_TEMPERATURE_STATE): dict,
|
||||
vol.Required(ATTR_SMART_TYPE): vol.In(
|
||||
["temperature", "feelsLike", "humidity"]
|
||||
|
||||
@@ -117,7 +117,7 @@
|
||||
"speed": {
|
||||
"default": "mdi:speedometer"
|
||||
},
|
||||
"sulfur_dioxide": {
|
||||
"sulphur_dioxide": {
|
||||
"default": "mdi:molecule"
|
||||
},
|
||||
"temperature": {
|
||||
|
||||
@@ -9,5 +9,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["async_upnp_client"],
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["async-upnp-client==0.38.1"]
|
||||
"requirements": ["async-upnp-client==0.38.2"]
|
||||
}
|
||||
|
||||
@@ -139,7 +139,7 @@ class StarlineSensor(StarlineEntity, SensorEntity):
|
||||
if self._key == "mileage" and self._device.mileage:
|
||||
return self._device.mileage.get("val")
|
||||
if self._key == "gps_count" and self._device.position:
|
||||
return self._device.position["sat_qty"]
|
||||
return self._device.position.get("sat_qty")
|
||||
return None
|
||||
|
||||
@property
|
||||
|
||||
@@ -39,5 +39,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/switchbot",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["switchbot"],
|
||||
"requirements": ["PySwitchbot==0.44.0"]
|
||||
"requirements": ["PySwitchbot==0.45.0"]
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
"""DataUpdateCoordinators for the System monitor integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import abstractmethod
|
||||
@@ -43,7 +44,8 @@ dataT = TypeVar(
|
||||
| sswap
|
||||
| VirtualMemory
|
||||
| tuple[float, float, float]
|
||||
| sdiskusage,
|
||||
| sdiskusage
|
||||
| None,
|
||||
)
|
||||
|
||||
|
||||
@@ -130,12 +132,15 @@ class SystemMonitorLoadCoordinator(MonitorCoordinator[tuple[float, float, float]
|
||||
return os.getloadavg()
|
||||
|
||||
|
||||
class SystemMonitorProcessorCoordinator(MonitorCoordinator[float]):
|
||||
class SystemMonitorProcessorCoordinator(MonitorCoordinator[float | None]):
|
||||
"""A System monitor Processor Data Update Coordinator."""
|
||||
|
||||
def update_data(self) -> float:
|
||||
def update_data(self) -> float | None:
|
||||
"""Fetch data."""
|
||||
return psutil.cpu_percent(interval=None)
|
||||
cpu_percent = psutil.cpu_percent(interval=None)
|
||||
if cpu_percent > 0.0:
|
||||
return cpu_percent
|
||||
return None
|
||||
|
||||
|
||||
class SystemMonitorBootTimeCoordinator(MonitorCoordinator[datetime]):
|
||||
|
||||
@@ -344,7 +344,9 @@ SENSOR_TYPES: dict[str, SysMonitorSensorEntityDescription[Any]] = {
|
||||
native_unit_of_measurement=PERCENTAGE,
|
||||
icon=get_cpu_icon(),
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_fn=lambda entity: round(entity.coordinator.data),
|
||||
value_fn=lambda entity: (
|
||||
round(entity.coordinator.data) if entity.coordinator.data else None
|
||||
),
|
||||
),
|
||||
"processor_temperature": SysMonitorSensorEntityDescription[
|
||||
dict[str, list[shwtemp]]
|
||||
|
||||
@@ -6,6 +6,6 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/technove",
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"requirements": ["python-technove==1.2.1"],
|
||||
"requirements": ["python-technove==1.2.2"],
|
||||
"zeroconf": ["_technove-stations._tcp.local."]
|
||||
}
|
||||
|
||||
@@ -63,7 +63,9 @@
|
||||
"state": {
|
||||
"unplugged": "Unplugged",
|
||||
"plugged_waiting": "Plugged, waiting",
|
||||
"plugged_charging": "Plugged, charging"
|
||||
"plugged_charging": "Plugged, charging",
|
||||
"out_of_activation_period": "Out of activation period",
|
||||
"high_charge_period": "High charge period"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,7 +61,7 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
async def async_step_dhcp(self, discovery_info: dhcp.DhcpServiceInfo) -> FlowResult:
|
||||
"""Handle discovery via dhcp."""
|
||||
return await self._async_handle_discovery(
|
||||
discovery_info.ip, discovery_info.macaddress
|
||||
discovery_info.ip, dr.format_mac(discovery_info.macaddress)
|
||||
)
|
||||
|
||||
async def async_step_integration_discovery(
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiounifi"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiounifi==70"],
|
||||
"requirements": ["aiounifi==71"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "device",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["async_upnp_client"],
|
||||
"requirements": ["async-upnp-client==0.38.1", "getmac==0.9.4"],
|
||||
"requirements": ["async-upnp-client==0.38.2", "getmac==0.9.4"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-upnp-org:device:InternetGatewayDevice:1"
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aio_geojson_usgs_earthquakes"],
|
||||
"requirements": ["aio-geojson-usgs-earthquakes==0.2"]
|
||||
"requirements": ["aio-geojson-usgs-earthquakes==0.3"]
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["async_upnp_client", "yeelight"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["yeelight==0.7.14", "async-upnp-client==0.38.1"],
|
||||
"requirements": ["yeelight==0.7.14", "async-upnp-client==0.38.2"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_miio._udp.local.",
|
||||
|
||||
@@ -19,8 +19,10 @@ from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
from homeassistant.helpers import (
|
||||
aiohttp_client,
|
||||
config_entry_oauth2_flow,
|
||||
config_validation as cv,
|
||||
device_registry as dr,
|
||||
)
|
||||
from homeassistant.helpers.typing import ConfigType
|
||||
|
||||
from . import api
|
||||
from .const import DOMAIN, YOLINK_EVENT
|
||||
@@ -30,6 +32,8 @@ from .services import async_register_services
|
||||
|
||||
SCAN_INTERVAL = timedelta(minutes=5)
|
||||
|
||||
CONFIG_SCHEMA = cv.empty_config_schema(DOMAIN)
|
||||
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
@@ -96,6 +100,14 @@ class YoLinkHomeStore:
|
||||
device_coordinators: dict[str, YoLinkCoordinator]
|
||||
|
||||
|
||||
async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool:
|
||||
"""Set up YoLink."""
|
||||
|
||||
async_register_services(hass)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up yolink from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
@@ -147,8 +159,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
)
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
|
||||
async_register_services(hass, entry)
|
||||
|
||||
async def async_yolink_unload(event) -> None:
|
||||
"""Unload yolink."""
|
||||
await yolink_home.async_unload()
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
|
||||
from yolink.client_request import ClientRequest
|
||||
from yolink.const import ATTR_DEVICE_SPEAKER_HUB
|
||||
@@ -30,6 +31,7 @@ class YoLinkNumberTypeConfigEntityDescription(NumberEntityDescription):
|
||||
"""YoLink NumberEntity description."""
|
||||
|
||||
exists_fn: Callable[[YoLinkDevice], bool]
|
||||
should_update_entity: Callable
|
||||
value: Callable
|
||||
|
||||
|
||||
@@ -37,6 +39,14 @@ NUMBER_TYPE_CONF_SUPPORT_DEVICES = [ATTR_DEVICE_SPEAKER_HUB]
|
||||
|
||||
SUPPORT_SET_VOLUME_DEVICES = [ATTR_DEVICE_SPEAKER_HUB]
|
||||
|
||||
|
||||
def get_volume_value(state: dict[str, Any]) -> int | None:
|
||||
"""Get volume option."""
|
||||
if (options := state.get("options")) is not None:
|
||||
return options.get("volume")
|
||||
return None
|
||||
|
||||
|
||||
DEVICE_CONFIG_DESCRIPTIONS: tuple[YoLinkNumberTypeConfigEntityDescription, ...] = (
|
||||
YoLinkNumberTypeConfigEntityDescription(
|
||||
key=OPTIONS_VALUME,
|
||||
@@ -48,7 +58,8 @@ DEVICE_CONFIG_DESCRIPTIONS: tuple[YoLinkNumberTypeConfigEntityDescription, ...]
|
||||
native_unit_of_measurement=None,
|
||||
icon="mdi:volume-high",
|
||||
exists_fn=lambda device: device.device_type in SUPPORT_SET_VOLUME_DEVICES,
|
||||
value=lambda state: state["options"]["volume"],
|
||||
should_update_entity=lambda value: value is not None,
|
||||
value=get_volume_value,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -98,7 +109,10 @@ class YoLinkNumberTypeConfigEntity(YoLinkEntity, NumberEntity):
|
||||
@callback
|
||||
def update_entity_state(self, state: dict) -> None:
|
||||
"""Update HA Entity State."""
|
||||
attr_val = self.entity_description.value(state)
|
||||
if (
|
||||
attr_val := self.entity_description.value(state)
|
||||
) is None and self.entity_description.should_update_entity(attr_val) is False:
|
||||
return
|
||||
self._attr_native_value = attr_val
|
||||
self.async_write_ha_state()
|
||||
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
import voluptuous as vol
|
||||
from yolink.client_request import ClientRequest
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant, ServiceCall
|
||||
from homeassistant.exceptions import ServiceValidationError
|
||||
from homeassistant.helpers import config_validation as cv, device_registry as dr
|
||||
|
||||
from .const import (
|
||||
@@ -19,7 +20,7 @@ from .const import (
|
||||
SERVICE_PLAY_ON_SPEAKER_HUB = "play_on_speaker_hub"
|
||||
|
||||
|
||||
def async_register_services(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
def async_register_services(hass: HomeAssistant) -> None:
|
||||
"""Register services for YoLink integration."""
|
||||
|
||||
async def handle_speaker_hub_play_call(service_call: ServiceCall) -> None:
|
||||
@@ -28,6 +29,17 @@ def async_register_services(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
device_registry = dr.async_get(hass)
|
||||
device_entry = device_registry.async_get(service_data[ATTR_TARGET_DEVICE])
|
||||
if device_entry is not None:
|
||||
for entry_id in device_entry.config_entries:
|
||||
if (entry := hass.config_entries.async_get_entry(entry_id)) is None:
|
||||
continue
|
||||
if entry.domain == DOMAIN:
|
||||
break
|
||||
if entry is None or entry.state == ConfigEntryState.NOT_LOADED:
|
||||
raise ServiceValidationError(
|
||||
"Config entry not found or not loaded!",
|
||||
translation_domain=DOMAIN,
|
||||
translation_key="invalid_config_entry",
|
||||
)
|
||||
home_store = hass.data[DOMAIN][entry.entry_id]
|
||||
for identifier in device_entry.identifiers:
|
||||
if (
|
||||
|
||||
@@ -7,9 +7,7 @@ play_on_speaker_hub:
|
||||
device:
|
||||
filter:
|
||||
- integration: yolink
|
||||
manufacturer: YoLink
|
||||
model: SpeakerHub
|
||||
|
||||
message:
|
||||
required: true
|
||||
example: hello, yolink
|
||||
|
||||
@@ -37,6 +37,11 @@
|
||||
"button_4_long_press": "Button_4 (long press)"
|
||||
}
|
||||
},
|
||||
"exceptions": {
|
||||
"invalid_config_entry": {
|
||||
"message": "Config entry not found or not loaded!"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"switch": {
|
||||
"usb_ports": { "name": "USB ports" },
|
||||
|
||||
@@ -135,7 +135,7 @@ def async_active_zone(
|
||||
is None
|
||||
# Skip zone that are outside the radius aka the
|
||||
# lat/long is outside the zone
|
||||
or not (zone_dist - (radius := zone_attrs[ATTR_RADIUS]) < radius)
|
||||
or not (zone_dist - (zone_radius := zone_attrs[ATTR_RADIUS]) < radius)
|
||||
):
|
||||
continue
|
||||
|
||||
@@ -144,7 +144,7 @@ def async_active_zone(
|
||||
zone_dist < min_dist
|
||||
or (
|
||||
# If same distance, prefer smaller zone
|
||||
zone_dist == min_dist and radius < closest.attributes[ATTR_RADIUS]
|
||||
zone_dist == min_dist and zone_radius < closest.attributes[ATTR_RADIUS]
|
||||
)
|
||||
):
|
||||
continue
|
||||
|
||||
@@ -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 = "0b11"
|
||||
PATCH_VERSION: Final = "2"
|
||||
__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]))
|
||||
|
||||
@@ -6,7 +6,7 @@ aiohttp-zlib-ng==0.3.1
|
||||
aiohttp==3.9.3
|
||||
aiohttp_cors==0.7.0
|
||||
astral==2.2
|
||||
async-upnp-client==0.38.1
|
||||
async-upnp-client==0.38.2
|
||||
atomicwrites-homeassistant==1.4.1
|
||||
attrs==23.2.0
|
||||
awesomeversion==24.2.0
|
||||
@@ -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
|
||||
@@ -36,7 +36,7 @@ janus==1.0.0
|
||||
Jinja2==3.1.3
|
||||
lru-dict==1.3.0
|
||||
mutagen==1.47.0
|
||||
orjson==3.9.13
|
||||
orjson==3.9.14
|
||||
packaging>=23.1
|
||||
paho-mqtt==1.6.1
|
||||
Pillow==10.2.0
|
||||
|
||||
@@ -4,5 +4,4 @@ ARG \
|
||||
FROM $BUILD_FROM
|
||||
|
||||
RUN apk --no-cache add \
|
||||
raspberrypi-userland \
|
||||
raspberrypi-userland-libs
|
||||
raspberrypi-utils
|
||||
|
||||
@@ -4,5 +4,4 @@ ARG \
|
||||
FROM $BUILD_FROM
|
||||
|
||||
RUN apk --no-cache add \
|
||||
raspberrypi-userland \
|
||||
raspberrypi-userland-libs
|
||||
raspberrypi-utils
|
||||
|
||||
@@ -4,5 +4,4 @@ ARG \
|
||||
FROM $BUILD_FROM
|
||||
|
||||
RUN apk --no-cache add \
|
||||
raspberrypi-userland \
|
||||
raspberrypi-userland-libs
|
||||
raspberrypi-utils
|
||||
|
||||
@@ -4,5 +4,4 @@ ARG \
|
||||
FROM $BUILD_FROM
|
||||
|
||||
RUN apk --no-cache add \
|
||||
raspberrypi-userland \
|
||||
raspberrypi-userland-libs
|
||||
raspberrypi-utils
|
||||
|
||||
@@ -4,5 +4,4 @@ ARG \
|
||||
FROM $BUILD_FROM
|
||||
|
||||
RUN apk --no-cache add \
|
||||
raspberrypi-userland \
|
||||
raspberrypi-userland-libs
|
||||
raspberrypi-utils
|
||||
|
||||
@@ -4,5 +4,4 @@ ARG \
|
||||
FROM $BUILD_FROM
|
||||
|
||||
RUN apk --no-cache add \
|
||||
raspberrypi-userland \
|
||||
raspberrypi-userland-libs
|
||||
raspberrypi-utils
|
||||
|
||||
@@ -4,5 +4,4 @@ ARG \
|
||||
FROM $BUILD_FROM
|
||||
|
||||
RUN apk --no-cache add \
|
||||
raspberrypi-userland \
|
||||
raspberrypi-userland-libs
|
||||
raspberrypi-utils
|
||||
|
||||
@@ -4,5 +4,4 @@ ARG \
|
||||
FROM $BUILD_FROM
|
||||
|
||||
RUN apk --no-cache add \
|
||||
raspberrypi-userland \
|
||||
raspberrypi-userland-libs
|
||||
raspberrypi-utils
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2024.2.0b11"
|
||||
version = "2024.2.2"
|
||||
license = {text = "Apache-2.0"}
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
readme = "README.rst"
|
||||
@@ -46,7 +46,7 @@ dependencies = [
|
||||
"cryptography==42.0.2",
|
||||
# pyOpenSSL 23.2.0 is required to work with cryptography 41+
|
||||
"pyOpenSSL==24.0.0",
|
||||
"orjson==3.9.13",
|
||||
"orjson==3.9.14",
|
||||
"packaging>=23.1",
|
||||
"pip>=21.3.1",
|
||||
"python-slugify==8.0.1",
|
||||
|
||||
@@ -22,7 +22,7 @@ lru-dict==1.3.0
|
||||
PyJWT==2.8.0
|
||||
cryptography==42.0.2
|
||||
pyOpenSSL==24.0.0
|
||||
orjson==3.9.13
|
||||
orjson==3.9.14
|
||||
packaging>=23.1
|
||||
pip>=21.3.1
|
||||
python-slugify==8.0.1
|
||||
|
||||
@@ -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
|
||||
@@ -96,7 +96,7 @@ PyRMVtransport==0.3.3
|
||||
PySocks==1.7.1
|
||||
|
||||
# homeassistant.components.switchbot
|
||||
PySwitchbot==0.44.0
|
||||
PySwitchbot==0.45.0
|
||||
|
||||
# homeassistant.components.switchmate
|
||||
PySwitchmate==0.5.1
|
||||
@@ -170,16 +170,16 @@ agent-py==0.0.23
|
||||
aio-geojson-generic-client==0.4
|
||||
|
||||
# homeassistant.components.geonetnz_quakes
|
||||
aio-geojson-geonetnz-quakes==0.15
|
||||
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
|
||||
|
||||
# homeassistant.components.usgs_earthquakes_feed
|
||||
aio-geojson-usgs-earthquakes==0.2
|
||||
aio-geojson-usgs-earthquakes==0.3
|
||||
|
||||
# homeassistant.components.gdacs
|
||||
aio-georss-gdacs==0.9
|
||||
@@ -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
|
||||
@@ -318,7 +318,7 @@ aiooncue==0.3.5
|
||||
aioopenexchangerates==0.4.0
|
||||
|
||||
# homeassistant.components.pegel_online
|
||||
aiopegelonline==0.0.6
|
||||
aiopegelonline==0.0.8
|
||||
|
||||
# homeassistant.components.acmeda
|
||||
aiopulse==0.4.4
|
||||
@@ -383,7 +383,7 @@ aiotankerkoenig==0.3.0
|
||||
aiotractive==0.5.6
|
||||
|
||||
# homeassistant.components.unifi
|
||||
aiounifi==70
|
||||
aiounifi==71
|
||||
|
||||
# homeassistant.components.vlc_telnet
|
||||
aiovlc==0.1.0
|
||||
@@ -478,7 +478,7 @@ asterisk_mbox==0.5.0
|
||||
# homeassistant.components.ssdp
|
||||
# homeassistant.components.upnp
|
||||
# homeassistant.components.yeelight
|
||||
async-upnp-client==0.38.1
|
||||
async-upnp-client==0.38.2
|
||||
|
||||
# homeassistant.components.keyboard_remote
|
||||
asyncinotify==4.0.2
|
||||
@@ -671,6 +671,9 @@ crownstone-uart==2.1.0
|
||||
# homeassistant.components.datadog
|
||||
datadog==0.15.0
|
||||
|
||||
# homeassistant.components.metoffice
|
||||
datapoint==0.9.9
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
dbus-fast==2.21.1
|
||||
|
||||
@@ -684,7 +687,7 @@ debugpy==1.8.0
|
||||
# decora==0.6
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
deebot-client==5.1.0
|
||||
deebot-client==5.2.1
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
@@ -818,7 +821,7 @@ eufylife-ble-client==0.1.8
|
||||
# evdev==1.6.1
|
||||
|
||||
# homeassistant.components.evohome
|
||||
evohome-async==0.4.17
|
||||
evohome-async==0.4.19
|
||||
|
||||
# homeassistant.components.faa_delays
|
||||
faadelays==2023.9.1
|
||||
@@ -1059,7 +1062,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
|
||||
@@ -1220,7 +1223,7 @@ lightwave==0.24
|
||||
limitlessled==1.1.3
|
||||
|
||||
# homeassistant.components.linear_garage_door
|
||||
linear-garage-door==0.2.7
|
||||
linear-garage-door==0.2.9
|
||||
|
||||
# homeassistant.components.linode
|
||||
linode-api==4.1.9b1
|
||||
@@ -1579,7 +1582,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
|
||||
@@ -1609,7 +1612,7 @@ py-nightscout==1.2.2
|
||||
py-schluter==0.1.7
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
py-sucks==0.9.8
|
||||
py-sucks==0.9.9
|
||||
|
||||
# homeassistant.components.synology_dsm
|
||||
py-synologydsm-api==2.1.4
|
||||
@@ -1928,7 +1931,7 @@ pylitterbot==2023.4.9
|
||||
pylutron-caseta==0.19.0
|
||||
|
||||
# homeassistant.components.lutron
|
||||
pylutron==0.2.8
|
||||
pylutron==0.2.12
|
||||
|
||||
# homeassistant.components.mailgun
|
||||
pymailgunner==1.4
|
||||
@@ -2238,7 +2241,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
|
||||
@@ -2284,7 +2287,7 @@ python-songpal==0.16.1
|
||||
python-tado==0.17.4
|
||||
|
||||
# homeassistant.components.technove
|
||||
python-technove==1.2.1
|
||||
python-technove==1.2.2
|
||||
|
||||
# homeassistant.components.telegram_bot
|
||||
python-telegram-bot==13.1
|
||||
@@ -2859,7 +2862,7 @@ xiaomi-ble==0.23.1
|
||||
xknx==2.12.0
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknxproject==3.5.0
|
||||
xknxproject==3.6.0
|
||||
|
||||
# homeassistant.components.bluesound
|
||||
# homeassistant.components.fritz
|
||||
@@ -2880,7 +2883,7 @@ yalesmartalarmclient==0.3.9
|
||||
yalexs-ble==2.4.1
|
||||
|
||||
# homeassistant.components.august
|
||||
yalexs==1.10.0
|
||||
yalexs==1.11.2
|
||||
|
||||
# homeassistant.components.yeelight
|
||||
yeelight==0.7.14
|
||||
|
||||
@@ -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
|
||||
@@ -84,7 +84,7 @@ PyRMVtransport==0.3.3
|
||||
PySocks==1.7.1
|
||||
|
||||
# homeassistant.components.switchbot
|
||||
PySwitchbot==0.44.0
|
||||
PySwitchbot==0.45.0
|
||||
|
||||
# homeassistant.components.syncthru
|
||||
PySyncThru==0.7.10
|
||||
@@ -149,16 +149,16 @@ agent-py==0.0.23
|
||||
aio-geojson-generic-client==0.4
|
||||
|
||||
# homeassistant.components.geonetnz_quakes
|
||||
aio-geojson-geonetnz-quakes==0.15
|
||||
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
|
||||
|
||||
# homeassistant.components.usgs_earthquakes_feed
|
||||
aio-geojson-usgs-earthquakes==0.2
|
||||
aio-geojson-usgs-earthquakes==0.3
|
||||
|
||||
# homeassistant.components.gdacs
|
||||
aio-georss-gdacs==0.9
|
||||
@@ -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
|
||||
@@ -291,7 +291,7 @@ aiooncue==0.3.5
|
||||
aioopenexchangerates==0.4.0
|
||||
|
||||
# homeassistant.components.pegel_online
|
||||
aiopegelonline==0.0.6
|
||||
aiopegelonline==0.0.8
|
||||
|
||||
# homeassistant.components.acmeda
|
||||
aiopulse==0.4.4
|
||||
@@ -356,7 +356,7 @@ aiotankerkoenig==0.3.0
|
||||
aiotractive==0.5.6
|
||||
|
||||
# homeassistant.components.unifi
|
||||
aiounifi==70
|
||||
aiounifi==71
|
||||
|
||||
# homeassistant.components.vlc_telnet
|
||||
aiovlc==0.1.0
|
||||
@@ -430,7 +430,7 @@ arcam-fmj==1.4.0
|
||||
# homeassistant.components.ssdp
|
||||
# homeassistant.components.upnp
|
||||
# homeassistant.components.yeelight
|
||||
async-upnp-client==0.38.1
|
||||
async-upnp-client==0.38.2
|
||||
|
||||
# homeassistant.components.sleepiq
|
||||
asyncsleepiq==1.5.2
|
||||
@@ -552,6 +552,9 @@ crownstone-uart==2.1.0
|
||||
# homeassistant.components.datadog
|
||||
datadog==0.15.0
|
||||
|
||||
# homeassistant.components.metoffice
|
||||
datapoint==0.9.9
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
dbus-fast==2.21.1
|
||||
|
||||
@@ -559,7 +562,7 @@ dbus-fast==2.21.1
|
||||
debugpy==1.8.0
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
deebot-client==5.1.0
|
||||
deebot-client==5.2.1
|
||||
|
||||
# homeassistant.components.ihc
|
||||
# homeassistant.components.namecheapdns
|
||||
@@ -855,7 +858,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
|
||||
@@ -971,7 +974,7 @@ librouteros==3.2.0
|
||||
libsoundtouch==0.8
|
||||
|
||||
# homeassistant.components.linear_garage_door
|
||||
linear-garage-door==0.2.7
|
||||
linear-garage-door==0.2.9
|
||||
|
||||
# homeassistant.components.lamarzocco
|
||||
lmcloud==0.4.35
|
||||
@@ -1232,7 +1235,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
|
||||
@@ -1259,7 +1262,7 @@ py-nextbusnext==1.0.2
|
||||
py-nightscout==1.2.2
|
||||
|
||||
# homeassistant.components.ecovacs
|
||||
py-sucks==0.9.8
|
||||
py-sucks==0.9.9
|
||||
|
||||
# homeassistant.components.synology_dsm
|
||||
py-synologydsm-api==2.1.4
|
||||
@@ -1485,7 +1488,7 @@ pylitterbot==2023.4.9
|
||||
pylutron-caseta==0.19.0
|
||||
|
||||
# homeassistant.components.lutron
|
||||
pylutron==0.2.8
|
||||
pylutron==0.2.12
|
||||
|
||||
# homeassistant.components.mailgun
|
||||
pymailgunner==1.4
|
||||
@@ -1711,7 +1714,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
|
||||
@@ -1751,7 +1754,7 @@ python-songpal==0.16.1
|
||||
python-tado==0.17.4
|
||||
|
||||
# homeassistant.components.technove
|
||||
python-technove==1.2.1
|
||||
python-technove==1.2.2
|
||||
|
||||
# homeassistant.components.telegram_bot
|
||||
python-telegram-bot==13.1
|
||||
@@ -2188,7 +2191,7 @@ xiaomi-ble==0.23.1
|
||||
xknx==2.12.0
|
||||
|
||||
# homeassistant.components.knx
|
||||
xknxproject==3.5.0
|
||||
xknxproject==3.6.0
|
||||
|
||||
# homeassistant.components.bluesound
|
||||
# homeassistant.components.fritz
|
||||
@@ -2206,7 +2209,7 @@ yalesmartalarmclient==0.3.9
|
||||
yalexs-ble==2.4.1
|
||||
|
||||
# homeassistant.components.august
|
||||
yalexs==1.10.0
|
||||
yalexs==1.11.2
|
||||
|
||||
# homeassistant.components.yeelight
|
||||
yeelight==0.7.14
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -5,6 +5,7 @@ from aioelectricitymaps import (
|
||||
ElectricityMapsConnectionError,
|
||||
ElectricityMapsError,
|
||||
ElectricityMapsInvalidTokenError,
|
||||
ElectricityMapsNoDataError,
|
||||
)
|
||||
import pytest
|
||||
|
||||
@@ -139,12 +140,9 @@ async def test_form_country(hass: HomeAssistant) -> None:
|
||||
),
|
||||
(ElectricityMapsError("Something else"), "unknown"),
|
||||
(ElectricityMapsConnectionError("Boom"), "unknown"),
|
||||
(ElectricityMapsNoDataError("I have no data"), "no_data"),
|
||||
],
|
||||
ids=[
|
||||
"invalid auth",
|
||||
"generic error",
|
||||
"json decode error",
|
||||
],
|
||||
ids=["invalid auth", "generic error", "json decode error", "no data error"],
|
||||
)
|
||||
async def test_form_error_handling(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -3,7 +3,7 @@ from collections.abc import Generator
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from deebot_client.const import PATH_API_APPSVR_APP
|
||||
from deebot_client import const
|
||||
from deebot_client.device import Device
|
||||
from deebot_client.exceptions import ApiError
|
||||
from deebot_client.models import Credentials
|
||||
@@ -75,9 +75,13 @@ def mock_authenticator(device_fixture: str) -> Generator[Mock, None, None]:
|
||||
query_params: dict[str, Any] | None = None,
|
||||
headers: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if path == PATH_API_APPSVR_APP:
|
||||
return {"code": 0, "devices": devices, "errno": "0"}
|
||||
raise ApiError("Path not mocked: {path}")
|
||||
match path:
|
||||
case const.PATH_API_APPSVR_APP:
|
||||
return {"code": 0, "devices": devices, "errno": "0"}
|
||||
case const.PATH_API_USERS_USER:
|
||||
return {"todo": "result", "result": "ok", "devices": devices}
|
||||
case _:
|
||||
raise ApiError("Path not mocked: {path}")
|
||||
|
||||
authenticator.post_authenticated.side_effect = post_authenticated
|
||||
yield authenticator
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -112,3 +112,14 @@ def mock_router_bridge_mode(mock_device_registry_devices, router):
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_router_bridge_mode_error(mock_device_registry_devices, router):
|
||||
"""Mock a failed connection to Freebox Bridge mode."""
|
||||
|
||||
router().lan.get_hosts_list = AsyncMock(
|
||||
side_effect=HttpRequestError("Request failed (APIResponse: some unknown error)")
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
@@ -69,8 +69,8 @@ async def test_zeroconf(hass: HomeAssistant) -> None:
|
||||
assert result["step_id"] == "link"
|
||||
|
||||
|
||||
async def test_link(hass: HomeAssistant, router: Mock) -> None:
|
||||
"""Test linking."""
|
||||
async def internal_test_link(hass: HomeAssistant) -> None:
|
||||
"""Test linking internal, common to both router modes."""
|
||||
with patch(
|
||||
"homeassistant.components.freebox.async_setup_entry",
|
||||
return_value=True,
|
||||
@@ -91,6 +91,30 @@ async def test_link(hass: HomeAssistant, router: Mock) -> None:
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_link(hass: HomeAssistant, router: Mock) -> None:
|
||||
"""Test link with standard router mode."""
|
||||
await internal_test_link(hass)
|
||||
|
||||
|
||||
async def test_link_bridge_mode(hass: HomeAssistant, router_bridge_mode: Mock) -> None:
|
||||
"""Test linking for a freebox in bridge mode."""
|
||||
await internal_test_link(hass)
|
||||
|
||||
|
||||
async def test_link_bridge_mode_error(
|
||||
hass: HomeAssistant, mock_router_bridge_mode_error: Mock
|
||||
) -> None:
|
||||
"""Test linking for a freebox in bridge mode, unknown error received from API."""
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN,
|
||||
context={"source": SOURCE_USER},
|
||||
data={CONF_HOST: MOCK_HOST, CONF_PORT: MOCK_PORT},
|
||||
)
|
||||
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
|
||||
assert result["type"] == data_entry_flow.FlowResultType.FORM
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
|
||||
|
||||
async def test_abort_if_already_setup(hass: HomeAssistant) -> None:
|
||||
"""Test we abort if component is already setup."""
|
||||
MockConfigEntry(
|
||||
|
||||
@@ -1,7 +1,11 @@
|
||||
"""Tests for the Freebox utility methods."""
|
||||
import json
|
||||
from unittest.mock import Mock
|
||||
|
||||
from homeassistant.components.freebox.router import is_json
|
||||
from freebox_api.exceptions import HttpRequestError
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.freebox.router import get_hosts_list_if_supported, is_json
|
||||
|
||||
from .const import DATA_LAN_GET_HOSTS_LIST_MODE_BRIDGE, DATA_WIFI_GET_GLOBAL_CONFIG
|
||||
|
||||
@@ -20,3 +24,33 @@ async def test_is_json() -> None:
|
||||
assert not is_json("")
|
||||
assert not is_json("XXX")
|
||||
assert not is_json("{XXX}")
|
||||
|
||||
|
||||
async def test_get_hosts_list_if_supported(
|
||||
router: Mock,
|
||||
) -> None:
|
||||
"""In router mode, get_hosts_list is supported and list is filled."""
|
||||
supports_hosts, fbx_devices = await get_hosts_list_if_supported(router())
|
||||
assert supports_hosts is True
|
||||
# List must not be empty; but it's content depends on how many unit tests are executed...
|
||||
assert fbx_devices
|
||||
assert "d633d0c8-958c-43cc-e807-d881b076924b" in str(fbx_devices)
|
||||
|
||||
|
||||
async def test_get_hosts_list_if_supported_bridge(
|
||||
router_bridge_mode: Mock,
|
||||
) -> None:
|
||||
"""In bridge mode, get_hosts_list is NOT supported and list is empty."""
|
||||
supports_hosts, fbx_devices = await get_hosts_list_if_supported(
|
||||
router_bridge_mode()
|
||||
)
|
||||
assert supports_hosts is False
|
||||
assert fbx_devices == []
|
||||
|
||||
|
||||
async def test_get_hosts_list_if_supported_bridge_error(
|
||||
mock_router_bridge_mode_error: Mock,
|
||||
) -> None:
|
||||
"""Other exceptions must be propagated."""
|
||||
with pytest.raises(HttpRequestError):
|
||||
await get_hosts_list_if_supported(mock_router_bridge_mode_error())
|
||||
|
||||
@@ -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(
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user