Compare commits

..

67 Commits

Author SHA1 Message Date
Franck Nijhof
7aa14e20d1 2024.2.2 (#110720) 2024-02-16 15:47:38 +01:00
Franck Nijhof
b55b2c8da3 Bump version to 2024.2.2 2024-02-16 14:13:26 +01:00
Robert Resch
8c05ebd031 Bump deebot-client to 5.2.1 (#110683)
* Bump deebot-client to 5.2.0

* Bumb again

* Fix tests
2024-02-16 14:13:18 +01:00
Robert Svensson
34a3e88e0d Bump aiounifi to v71 (#110658) 2024-02-16 14:13:15 +01:00
J. Nick Koston
bf002ac0b0 Fix elkm1 service calls running in the executor (#110655)
fixes
```
  File "/usr/src/homeassistant/homeassistant/components/elkm1/__init__.py", line 416, in _set_time_service
    _getelk(service).panel.set_time(dt_util.now())
  File "/usr/local/lib/python3.11/site-packages/elkm1_lib/panel.py", line 55, in set_time
    self._connection.send(rw_encode(datetime))
  File "/usr/local/lib/python3.11/site-packages/elkm1_lib/connection.py", line 152, in send
    self._send(QueuedWrite(msg.message, msg.response_command), priority_send)
  File "/usr/local/lib/python3.11/site-packages/elkm1_lib/connection.py", line 148, in _send
    self._check_write_queue.set()
  File "/usr/local/lib/python3.11/asyncio/locks.py", line 192, in set
    fut.set_result(True)
  File "/usr/local/lib/python3.11/asyncio/base_events.py", line 763, in call_soon
    self._check_thread()
  File "/usr/local/lib/python3.11/asyncio/base_events.py", line 800, in _check_thread
    raise RuntimeError(
RuntimeError: Non-thread-safe operation invoked on an event loop other than the current one
```
2024-02-16 14:13:12 +01:00
jan iversen
6f529a2c77 Modbus, allow received int to be a float. (#110648) 2024-02-16 14:13:09 +01:00
J. Nick Koston
e5db7278e1 Fix tplink not updating IP from DHCP discovery and discovering twice (#110557)
We only called format_mac on the mac address if we connected
to the device during entry creation. Since the format of the
mac address from DHCP discovery did not match the format saved
in the unique id, the IP would not get updated and a second
discovery would appear

Thankfully the creation path does format the mac so we did not
create any entries with an inconsistantly formatted unique id

fixes #110460
2024-02-16 14:13:05 +01:00
J. Nick Koston
cdf67e9bb5 Bump orjson to 3.9.14 (#110552)
changelog: https://github.com/ijl/orjson/compare/3.9.13...3.9.14

fixes a crasher due to buffer overread (was only partially fixed in 3.9.13)
2024-02-16 14:13:03 +01:00
G Johansson
393359a546 Coerce to float in Sensibo climate react custom service (#110508) 2024-02-16 14:12:59 +01:00
Stefan Agner
9309e38302 Fix Raspberry Pi utilities installation on Alpine 3.19 (#110463) 2024-02-16 14:12:56 +01:00
wilburCforce
479ecc8b94 Update pylutron to 0.2.12 (#110414) 2024-02-16 14:12:53 +01:00
wilburCforce
ec7950aeda Update pylutron to 0.2.11 (#109853) 2024-02-16 14:12:48 +01:00
Robert Hillis
c763483049 Mitigate session closed error in Netgear LTE (#110412) 2024-02-16 14:10:34 +01:00
Steven Looman
fe84e7a576 Bump async-upnp-client to 0.38.2 (#110411) 2024-02-16 14:10:31 +01:00
Michael
5ba31290b8 Bump py-sucks to 0.9.9 (#110397)
bump py-sucks to 0.9.9
2024-02-16 14:10:28 +01:00
J. Nick Koston
de619e4ddc Fix zone radius calculation when radius is not 0 (#110354) 2024-02-16 14:10:25 +01:00
Nikolay Vasilchuk
56ceadaeeb Fix Starline GPS count sensor (#110348) 2024-02-16 14:10:22 +01:00
IceBotYT
da61564f82 Bump linear-garage-door to 0.2.9 (#110298) 2024-02-16 14:10:19 +01:00
starkillerOG
003673cd29 Fix TDBU naming in Motionblinds (#110283)
fix TDBU naming
2024-02-16 14:10:16 +01:00
Matthias Alphart
da6c571e65 Update xknxproject to 3.6.0 (#110282) 2024-02-16 14:10:13 +01:00
J. Nick Koston
159fab7025 Bump PySwitchbot to 0.45.0 (#110275) 2024-02-16 14:10:10 +01:00
Michael
96a10e76b8 Bump aiopegelonline to 0.0.8 (#110274) 2024-02-16 14:10:08 +01:00
G Johansson
e7068ae134 Fix cpu percentage in System Monitor (#110268)
* Fix cpu percentage in System Monitor

* Tests
2024-02-16 14:10:05 +01:00
Jan-Philipp Benecke
6a0c3f1b4f Handle no data error in Electricity Maps config flow (#110259)
Co-authored-by: Viktor Andersson <30777521+VIKTORVAV99@users.noreply.github.com>
2024-02-16 14:10:02 +01:00
Simon Lamon
a0ae18a1b6 Fix state classes issue translation in Group (#110238)
Fix state classes translation
2024-02-16 14:09:59 +01:00
David Bonnes
ad761bb2de Bump evohome-async to 0.4.19 (#110225)
bump client to 0.4.19
2024-02-16 14:09:56 +01:00
DustyArmstrong
edb69fb095 Bump datapoint to 0.9.9 + re-enable Met Office Integration (#110206) 2024-02-16 14:09:53 +01:00
Simon Lamon
58b28e6df1 Fix device class repairs issues placeholders in Group (#110181)
fix translation placeholders
2024-02-16 14:09:50 +01:00
Adam Goode
973a13abfa Properly report cover positions to prometheus (#110157) 2024-02-16 14:09:47 +01:00
J. Nick Koston
2a51377cef Bump yalexs to 1.11.2 (#110144)
changelog: https://github.com/bdraco/yalexs/compare/v1.11.1...v1.11.2
2024-02-16 14:09:44 +01:00
J. Nick Koston
87bd67656b Only schedule august activity update when a new activity is seen (#110141) 2024-02-16 14:09:41 +01:00
Maciej Bieniek
c79bc17d17 Fix typo in sensor icons configuration (#110133)
Co-authored-by: Maciej Bieniek <478555+bieniu@users.noreply.github.com>
2024-02-16 14:09:38 +01:00
A Björck
54270df217 Bump yalexs to 1.11.1, fixing camera snapshots from Yale Home (#110089) 2024-02-16 14:09:35 +01:00
Malte Franken
5a87cde71e Bump aio-geojson-usgs-earthquakes to 0.3 (#110084) 2024-02-16 14:09:32 +01:00
Christophe Gagnier
e825bcc282 Update pytechnove to 1.2.2 (#110074) 2024-02-16 14:09:29 +01:00
Aurélien Grenotton
b54a3170f0 Fix freebox pairing in bridge mode (#106131) 2024-02-16 14:09:25 +01:00
Luke Lashley
349d8f5c28 Better teardown and setup of Roborock connections (#106092)
Co-authored-by: Robert Resch <robert@resch.dev>
2024-02-16 14:08:20 +01:00
Franck Nijhof
cfd1f7809f 2024.2.1 (#110078) 2024-02-09 11:04:19 +01:00
Erik Montnemery
5f9cc2fec1 Prevent network access in emulated_hue tests (#109991) 2024-02-09 10:16:49 +01:00
Franck Nijhof
58d46f6dec Bump version to 2024.2.1 2024-02-09 09:02:01 +01:00
Brandon Rothweiler
74ea9e24df Bump py-aosmith to 1.0.8 (#110061) 2024-02-09 09:01:49 +01:00
David Bonnes
437a2a829f Bump evohome-async to 0.4.18 (#110056) 2024-02-09 09:01:46 +01:00
Michael Hansen
f5884c6279 Matching duplicate named entities is now an error in Assist (#110050)
* Matching duplicate named entities is now an error

* Update snapshot

* Only use area id
2024-02-09 09:01:43 +01:00
Michael
e4382a494c Log error and continue on parsing issues of translated strings (#110046) 2024-02-09 09:00:19 +01:00
Bram Kragten
56ff767969 Update frontend to 20240207.1 (#110039) 2024-02-09 09:00:17 +01:00
jan iversen
4a18f592c6 Avoid key_error in modbus climate with non-defined fan_mode. (#110017) 2024-02-09 09:00:14 +01:00
Robert Resch
7ff2f376d4 Bump aioecowitt to 2024.2.1 (#109999) 2024-02-09 09:00:10 +01:00
jan iversen
a18918bb73 Allow modbus negative min/max value. (#109995) 2024-02-09 09:00:06 +01:00
Robert Resch
49e5709826 Bump deebot-client to 5.1.1 (#109994) 2024-02-09 09:00:00 +01:00
jan iversen
c665903f9d Allow modbus min/max temperature to be negative. (#109977) 2024-02-09 08:59:58 +01:00
spycle
de44af2948 Bump pyMicrobot to 0.0.12 (#109970) 2024-02-09 08:59:55 +01:00
Erik Montnemery
95a800b6bc Don't blow up if config entries have unhashable unique IDs (#109966)
* Don't blow up if config entries have unhashable unique IDs

* Add test

* Add comment on when we remove the guard

* Don't stringify hashable non string unique_id
2024-02-09 08:59:52 +01:00
jan iversen
a9e9ec2c3d Allow modbus "scale" to be negative. (#109965) 2024-02-09 08:59:49 +01:00
Marcel van der Veldt
7309c3c290 Handle Matter nodes that become available after startup is done (#109956) 2024-02-09 08:59:46 +01:00
Malte Franken
f48d70654b Bump aio-geojson-geonetnz-volcano to 0.9 (#109940) 2024-02-09 08:59:43 +01:00
Marcel van der Veldt
a9b3c2e2b5 Skip polling of unavailable Matter nodes (#109917) 2024-02-09 08:59:41 +01:00
Jan-Philipp Benecke
19349e1779 Bump aioelectricitymaps to 0.4.0 (#109895) 2024-02-09 08:59:38 +01:00
Marcel van der Veldt
e320d715c7 Bump Python matter server to 5.5.0 (#109894) 2024-02-09 08:59:35 +01:00
Michael Hansen
44c9ea68eb Assist fixes (#109889)
* Don't pass entity ids in hassil slot lists

* Use first completed response

* Add more tests
2024-02-09 08:59:32 +01:00
Mike Degatano
dbfee24eb7 Allow disabling home assistant watchdog (#109818) 2024-02-09 08:59:27 +01:00
mkmer
3b7271d597 Catch APIRateLimit in Honeywell (#107806) 2024-02-09 08:58:44 +01:00
Franck Nijhof
9dbf84228e 2024.2.0 (#109883) 2024-02-07 18:31:28 +01:00
Joost Lekkerkerker
9e47d03086 Fix kitchen sink tests (#109243) 2024-02-07 17:40:10 +01:00
Franck Nijhof
f63aaf8b5a Bump version to 2024.2.0 2024-02-07 16:28:11 +01:00
Malte Franken
8375fc235d Bump aio-geojson-geonetnz-quakes to 0.16 (#109873) 2024-02-07 16:27:47 +01:00
Åke Strandberg
3030870de0 Remove soft hyphens from myuplink sensor names (#109845)
Remove soft hyphens from sensor names
2024-02-07 16:27:44 +01:00
Matrix
f61c70b686 Fix YoLink SpeakerHub support (#107925)
* improve

* Fix when hub offline/online message pushing

* fix as suggestion

* check config entry load state

* Add exception translation
2024-02-07 16:27:40 +01:00
119 changed files with 1497 additions and 398 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aiopegelonline"],
"requirements": ["aiopegelonline==0.0.6"]
"requirements": ["aiopegelonline==0.0.8"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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": [
{

View File

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

View File

@@ -117,7 +117,7 @@
"speed": {
"default": "mdi:speedometer"
},
"sulfur_dioxide": {
"sulphur_dioxide": {
"default": "mdi:molecule"
},
"temperature": {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["aiounifi"],
"quality_scale": "platinum",
"requirements": ["aiounifi==70"],
"requirements": ["aiounifi==71"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,9 +7,7 @@ play_on_speaker_hub:
device:
filter:
- integration: yolink
manufacturer: YoLink
model: SpeakerHub
message:
required: true
example: hello, yolink

View File

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

View File

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

View File

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

View File

@@ -16,7 +16,7 @@ from .helpers.deprecation import (
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 2
PATCH_VERSION: Final = "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)

View File

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

View File

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

View File

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

View File

@@ -4,5 +4,4 @@ ARG \
FROM $BUILD_FROM
RUN apk --no-cache add \
raspberrypi-userland \
raspberrypi-userland-libs
raspberrypi-utils

View File

@@ -4,5 +4,4 @@ ARG \
FROM $BUILD_FROM
RUN apk --no-cache add \
raspberrypi-userland \
raspberrypi-userland-libs
raspberrypi-utils

View File

@@ -4,5 +4,4 @@ ARG \
FROM $BUILD_FROM
RUN apk --no-cache add \
raspberrypi-userland \
raspberrypi-userland-libs
raspberrypi-utils

View File

@@ -4,5 +4,4 @@ ARG \
FROM $BUILD_FROM
RUN apk --no-cache add \
raspberrypi-userland \
raspberrypi-userland-libs
raspberrypi-utils

View File

@@ -4,5 +4,4 @@ ARG \
FROM $BUILD_FROM
RUN apk --no-cache add \
raspberrypi-userland \
raspberrypi-userland-libs
raspberrypi-utils

View File

@@ -4,5 +4,4 @@ ARG \
FROM $BUILD_FROM
RUN apk --no-cache add \
raspberrypi-userland \
raspberrypi-userland-libs
raspberrypi-utils

View File

@@ -4,5 +4,4 @@ ARG \
FROM $BUILD_FROM
RUN apk --no-cache add \
raspberrypi-userland \
raspberrypi-userland-libs
raspberrypi-utils

View File

@@ -4,5 +4,4 @@ ARG \
FROM $BUILD_FROM
RUN apk --no-cache add \
raspberrypi-userland \
raspberrypi-userland-libs
raspberrypi-utils

View File

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

View File

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

View File

@@ -76,7 +76,7 @@ PyMetEireann==2021.8.0
PyMetno==0.11.0
# homeassistant.components.keymitt_ble
PyMicroBot==0.0.10
PyMicroBot==0.0.12
# homeassistant.components.nina
PyNINA==0.3.3
@@ -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

View File

@@ -64,7 +64,7 @@ PyMetEireann==2021.8.0
PyMetno==0.11.0
# homeassistant.components.keymitt_ble
PyMicroBot==0.0.10
PyMicroBot==0.0.12
# homeassistant.components.nina
PyNINA==0.3.3
@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Some files were not shown because too many files have changed in this diff Show More