This commit is contained in:
Franck Nijhof 2024-05-17 15:04:13 +02:00 committed by GitHub
commit 3dc3de95fa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
49 changed files with 11064 additions and 126 deletions

View File

@ -82,6 +82,9 @@ class HomeassistantAnalyticsConfigFlow(ConfigFlow, domain=DOMAIN):
except HomeassistantAnalyticsConnectionError:
LOGGER.exception("Error connecting to Home Assistant analytics")
return self.async_abort(reason="cannot_connect")
except Exception: # pylint: disable=broad-except
LOGGER.exception("Unexpected error")
return self.async_abort(reason="unknown")
options = [
SelectOptionDict(

View File

@ -13,7 +13,8 @@
}
},
"abort": {
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]"
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"unknown": "[%key:common::config_flow::error::unknown%]"
},
"error": {
"no_integration_selected": "You must select at least one integration to track"

View File

@ -15,6 +15,7 @@ class AuroraEntity(CoordinatorEntity[AuroraDataUpdateCoordinator]):
"""Implementation of the base Aurora Entity."""
_attr_attribution = ATTRIBUTION
_attr_has_entity_name = True
def __init__(
self,

View File

@ -2,13 +2,13 @@
from __future__ import annotations
from typing import Any, TypedDict, TypeVar
from typing import Any, TypedDict, TypeVar, cast
from pydeconz.interfaces.groups import GroupHandler
from pydeconz.interfaces.lights import LightHandler
from pydeconz.models import ResourceType
from pydeconz.models.event import EventType
from pydeconz.models.group import Group
from pydeconz.models.group import Group, TypedGroupAction
from pydeconz.models.light.light import Light, LightAlert, LightColorMode, LightEffect
from homeassistant.components.light import (
@ -105,6 +105,23 @@ class SetStateAttributes(TypedDict, total=False):
xy: tuple[float, float]
def update_color_state(
group: Group, lights: list[Light], override: bool = False
) -> None:
"""Sync group color state with light."""
data = {
attribute: light_attribute
for light in lights
for attribute in ("bri", "ct", "hue", "sat", "xy", "colormode", "effect")
if (light_attribute := light.raw["state"].get(attribute)) is not None
}
if override:
group.raw["action"] = cast(TypedGroupAction, data)
else:
group.update(cast(dict[str, dict[str, Any]], {"action": data}))
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@ -148,11 +165,12 @@ async def async_setup_entry(
if (group := hub.api.groups[group_id]) and not group.lights:
return
first = True
for light_id in group.lights:
if (light := hub.api.lights.lights.get(light_id)) and light.reachable:
group.update_color_state(light, update_all_attributes=first)
first = False
lights = [
light
for light_id in group.lights
if (light := hub.api.lights.lights.get(light_id)) and light.reachable
]
update_color_state(group, lights, True)
async_add_entities([DeconzGroup(group, hub)])
@ -326,7 +344,7 @@ class DeconzLight(DeconzBaseLight[Light]):
if self._device.reachable and "attr" not in self._device.changed_keys:
for group in self.hub.api.groups.values():
if self._device.resource_id in group.lights:
group.update_color_state(self._device)
update_color_state(group, [self._device])
class DeconzGroup(DeconzBaseLight[Group]):

View File

@ -41,6 +41,11 @@ class DuotecnoEntity(Entity):
"""When a unit has an update."""
self.async_write_ha_state()
@property
def available(self) -> bool:
"""Available state for the unit."""
return self._unit.is_available()
_T = TypeVar("_T", bound="DuotecnoEntity")
_P = ParamSpec("_P")

View File

@ -7,5 +7,5 @@
"iot_class": "local_push",
"loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"],
"quality_scale": "silver",
"requirements": ["pyDuotecno==2024.3.2"]
"requirements": ["pyDuotecno==2024.5.0"]
}

View File

@ -69,18 +69,21 @@ async def async_setup_services(hass: HomeAssistant) -> None:
async def async_set_config(call: ServiceCall) -> None:
"""Set a Fully Kiosk Browser config value on the device."""
for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]):
key = call.data[ATTR_KEY]
value = call.data[ATTR_VALUE]
# Fully API has different methods for setting string and bool values.
# check if call.data[ATTR_VALUE] is a bool
if isinstance(call.data[ATTR_VALUE], bool) or call.data[
ATTR_VALUE
].lower() in ("true", "false"):
await coordinator.fully.setConfigurationBool(
call.data[ATTR_KEY], call.data[ATTR_VALUE]
)
if isinstance(value, bool) or (
isinstance(value, str) and value.lower() in ("true", "false")
):
await coordinator.fully.setConfigurationBool(key, value)
else:
await coordinator.fully.setConfigurationString(
call.data[ATTR_KEY], call.data[ATTR_VALUE]
)
# Convert any int values to string
if isinstance(value, int):
value = str(value)
await coordinator.fully.setConfigurationString(key, value)
# Register all the above services
service_mapping = [
@ -111,7 +114,7 @@ async def async_setup_services(hass: HomeAssistant) -> None:
{
vol.Required(ATTR_DEVICE_ID): cv.ensure_list,
vol.Required(ATTR_KEY): cv.string,
vol.Required(ATTR_VALUE): vol.Any(str, bool),
vol.Required(ATTR_VALUE): vol.Any(str, bool, int),
}
)
),

View File

@ -169,7 +169,9 @@ class GoogleAssistantConversationAgent(conversation.AbstractConversationAgent):
self.language = user_input.language
self.assistant = TextAssistant(credentials, self.language)
resp = self.assistant.assist(user_input.text)
resp = await self.hass.async_add_executor_job(
self.assistant.assist, user_input.text
)
text_response = resp[0] or "<empty response>"
intent_response = intent.IntentResponse(language=user_input.language)

View File

@ -79,7 +79,7 @@ async def async_send_text_commands(
) as assistant:
command_response_list = []
for command in commands:
resp = assistant.assist(command)
resp = await hass.async_add_executor_job(assistant.assist, command)
text_response = resp[0]
_LOGGER.debug("command: %s\nresponse: %s", command, text_response)
audio_response = resp[2]

View File

@ -182,11 +182,11 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent):
conversation_id = ulid.ulid_now()
messages = [{}, {}]
intent_response = intent.IntentResponse(language=user_input.language)
try:
prompt = self._async_generate_prompt(raw_prompt)
except TemplateError as err:
_LOGGER.error("Error rendering prompt: %s", err)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
f"Sorry, I had a problem with my template: {err}",
@ -210,7 +210,6 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent):
genai_types.StopCandidateException,
) as err:
_LOGGER.error("Error sending message: %s", err)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
f"Sorry, I had a problem talking to Google Generative AI: {err}",
@ -220,9 +219,15 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent):
)
_LOGGER.debug("Response: %s", chat_response.parts)
if not chat_response.parts:
intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN,
"Sorry, I had a problem talking to Google Generative AI. Likely blocked",
)
return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id
)
self.history[conversation_id] = chat.history
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_speech(chat_response.text)
return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id

View File

@ -57,6 +57,11 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator
if "amc:api" not in entry.data["token"]["scope"]:
# We raise ConfigEntryAuthFailed here because the websocket can't be used
# without the scope. So only polling would be possible.
raise ConfigEntryAuthFailed
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@ -13,7 +13,9 @@ from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN, NAME
_LOGGER = logging.getLogger(__name__)
CONF_USER_ID = "user_id"
HUSQVARNA_DEV_PORTAL_URL = "https://developer.husqvarnagroup.cloud/applications"
class HusqvarnaConfigFlowHandler(
@ -29,8 +31,14 @@ class HusqvarnaConfigFlowHandler(
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for the flow."""
token = data[CONF_TOKEN]
if "amc:api" not in token["scope"] and not self.reauth_entry:
return self.async_abort(reason="missing_amc_scope")
user_id = token[CONF_USER_ID]
if self.reauth_entry:
if "amc:api" not in token["scope"]:
return self.async_update_reload_and_abort(
self.reauth_entry, data=data, reason="missing_amc_scope"
)
if self.reauth_entry.unique_id != user_id:
return self.async_abort(reason="wrong_account")
return self.async_update_reload_and_abort(self.reauth_entry, data=data)
@ -56,6 +64,9 @@ class HusqvarnaConfigFlowHandler(
self.reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"]
)
if self.reauth_entry is not None:
if "amc:api" not in self.reauth_entry.data["token"]["scope"]:
return await self.async_step_missing_scope()
return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm(
@ -65,3 +76,19 @@ class HusqvarnaConfigFlowHandler(
if user_input is None:
return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user()
async def async_step_missing_scope(
self, user_input: dict[str, Any] | None = None
) -> ConfigFlowResult:
"""Confirm reauth for missing scope."""
if user_input is None and self.reauth_entry is not None:
token_structured = structure_token(
self.reauth_entry.data["token"]["access_token"]
)
return self.async_show_form(
step_id="missing_scope",
description_placeholders={
"application_url": f"{HUSQVARNA_DEV_PORTAL_URL}/{token_structured.client_id}"
},
)
return await self.async_step_user()

View File

@ -4,12 +4,17 @@ import asyncio
from datetime import timedelta
import logging
from aioautomower.exceptions import ApiException, HusqvarnaWSServerHandshakeError
from aioautomower.exceptions import (
ApiException,
AuthException,
HusqvarnaWSServerHandshakeError,
)
from aioautomower.model import MowerAttributes
from aioautomower.session import AutomowerSession
from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN
@ -46,6 +51,8 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
return await self.api.get_status()
except ApiException as err:
raise UpdateFailed(err) from err
except AuthException as err:
raise ConfigEntryAuthFailed(err) from err
@callback
def callback(self, ws_data: dict[str, MowerAttributes]) -> None:

View File

@ -5,6 +5,10 @@
"title": "[%key:common::config_flow::title::reauth%]",
"description": "The Husqvarna Automower integration needs to re-authenticate your account"
},
"missing_scope": {
"title": "Your account is missing some API connections",
"description": "For the best experience with this integration both the `Authentication API` and the `Automower Connect API` should be connected. Please make sure that both of them are connected to your account in the [Husqvarna Developer Portal]({application_url})."
},
"pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_pick_implementation%]"
}
@ -22,7 +26,8 @@
"oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]",
"wrong_account": "You can only reauthenticate this entry with the same Husqvarna account."
"wrong_account": "You can only reauthenticate this entry with the same Husqvarna account.",
"missing_amc_scope": "The `Authentication API` and the `Automower Connect API` are not connected to your application in the Husqvarna Developer Portal."
},
"create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

@ -22,6 +22,7 @@ from .const import (
CONF_CAT,
CONF_DIM_STEPS,
CONF_HOUSECODE,
CONF_HUB_VERSION,
CONF_SUBCAT,
CONF_UNITCODE,
HOUSECODES,
@ -143,6 +144,7 @@ def build_hub_schema(
schema = {
vol.Required(CONF_HOST, default=host): str,
vol.Required(CONF_PORT, default=port): int,
vol.Required(CONF_HUB_VERSION, default=hub_version): int,
}
if hub_version == 2:
schema[vol.Required(CONF_USERNAME, default=username)] = str

View File

@ -480,7 +480,13 @@ class KodiEntity(MediaPlayerEntity):
self._reset_state()
return
self._players = await self._kodi.get_players()
try:
self._players = await self._kodi.get_players()
except (TransportError, ProtocolError):
if not self._connection.can_subscribe:
self._reset_state()
return
raise
if self._kodi_is_off:
self._reset_state()

View File

@ -96,7 +96,7 @@ class LutronCasetaTiltOnlyBlind(LutronCasetaDeviceUpdatableEntity, CoverEntity):
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the blind to a specific tilt."""
self._smartbridge.set_tilt(self.device_id, kwargs[ATTR_TILT_POSITION])
await self._smartbridge.set_tilt(self.device_id, kwargs[ATTR_TILT_POSITION])
PYLUTRON_TYPE_TO_CLASSES = {

View File

@ -83,8 +83,18 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__)
MIN_BUFFER_SIZE = 131072 # Minimum buffer size to use if preferred size fails
PREFERRED_BUFFER_SIZE = 2097152 # Set receive buffer size to 2MB
DISCOVERY_COOLDOWN = 5
INITIAL_SUBSCRIBE_COOLDOWN = 3.0
# The initial subscribe cooldown controls how long to wait to group
# subscriptions together. This is to avoid making too many subscribe
# requests in a short period of time. If the number is too low, the
# system will be flooded with subscribe requests. If the number is too
# high, we risk being flooded with responses to the subscribe requests
# which can exceed the receive buffer size of the socket. To mitigate
# this, we increase the receive buffer size of the socket as well.
INITIAL_SUBSCRIBE_COOLDOWN = 0.5
SUBSCRIBE_COOLDOWN = 0.1
UNSUBSCRIBE_COOLDOWN = 0.1
TIMEOUT_ACK = 10
@ -429,6 +439,7 @@ class MQTT:
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self._async_ha_stop),
)
)
self._socket_buffersize: int | None = None
@callback
def _async_ha_started(self, _hass: HomeAssistant) -> None:
@ -529,6 +540,29 @@ class MQTT:
self.hass, self._misc_loop(), name="mqtt misc loop"
)
def _increase_socket_buffer_size(self, sock: SocketType) -> None:
"""Increase the socket buffer size."""
new_buffer_size = PREFERRED_BUFFER_SIZE
while True:
try:
# Some operating systems do not allow us to set the preferred
# buffer size. In that case we try some other size options.
sock.setsockopt(socket.SOL_SOCKET, socket.SO_RCVBUF, new_buffer_size)
except OSError as err:
if new_buffer_size <= MIN_BUFFER_SIZE:
_LOGGER.warning(
"Unable to increase the socket buffer size to %s; "
"The connection may be unstable if the MQTT broker "
"sends data at volume or a large amount of subscriptions "
"need to be processed: %s",
new_buffer_size,
err,
)
return
new_buffer_size //= 2
else:
return
def _on_socket_open(
self, client: mqtt.Client, userdata: Any, sock: SocketType
) -> None:
@ -545,6 +579,7 @@ class MQTT:
fileno = sock.fileno()
_LOGGER.debug("%s: connection opened %s", self.config_entry.title, fileno)
if fileno > -1:
self._increase_socket_buffer_size(sock)
self.loop.add_reader(sock, partial(self._async_reader_callback, client))
self._async_start_misc_loop()

View File

@ -24,7 +24,7 @@ class OndiloIcoCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
hass,
logger=_LOGGER,
name=DOMAIN,
update_interval=timedelta(minutes=5),
update_interval=timedelta(minutes=20),
)
self.api = api

View File

@ -55,7 +55,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
use_tls = entry.data[CONF_SSL]
verify_tls = entry.data[CONF_VERIFY_SSL]
location = entry.data[CONF_LOCATION]
api_key = entry.data.get(CONF_API_KEY)
api_key = entry.data.get(CONF_API_KEY, "")
# remove obsolet CONF_STATISTICS_ONLY from entry.data
if CONF_STATISTICS_ONLY in entry.data:

View File

@ -1,9 +1,10 @@
"""Base entity for poolsense integration."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION
from .const import ATTRIBUTION, DOMAIN
from .coordinator import PoolSenseDataUpdateCoordinator
@ -11,6 +12,7 @@ class PoolSenseEntity(CoordinatorEntity[PoolSenseDataUpdateCoordinator]):
"""Implements a common class elements representing the PoolSense component."""
_attr_attribution = ATTRIBUTION
_attr_has_entity_name = True
def __init__(
self,
@ -21,5 +23,8 @@ class PoolSenseEntity(CoordinatorEntity[PoolSenseDataUpdateCoordinator]):
"""Initialize poolsense sensor."""
super().__init__(coordinator)
self.entity_description = description
self._attr_name = f"PoolSense {description.name}"
self._attr_unique_id = f"{email}-{description.key}"
self._attr_device_info = DeviceInfo(
identifiers={(DOMAIN, email)},
model="PoolSense",
)

View File

@ -85,6 +85,7 @@ async def async_setup_entry(hass: HomeAssistant, config_entry: ConfigEntry) -> b
try:
await host.update_states()
except CredentialsInvalidError as err:
await host.stop()
raise ConfigEntryAuthFailed(err) from err
except ReolinkError as err:
raise UpdateFailed(str(err)) from err

View File

@ -18,5 +18,5 @@
"documentation": "https://www.home-assistant.io/integrations/reolink",
"iot_class": "local_push",
"loggers": ["reolink_aio"],
"requirements": ["reolink-aio==0.8.9"]
"requirements": ["reolink-aio==0.8.10"]
}

View File

@ -8,7 +8,7 @@
"documentation": "https://www.home-assistant.io/integrations/sonos",
"iot_class": "local_push",
"loggers": ["soco"],
"requirements": ["soco==0.30.3", "sonos-websocket==0.1.3"],
"requirements": ["soco==0.30.4", "sonos-websocket==0.1.3"],
"ssdp": [
{
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"

View File

@ -92,7 +92,7 @@ SQUEEZEBOX_MODE = {
}
async def start_server_discovery(hass):
async def start_server_discovery(hass: HomeAssistant) -> None:
"""Start a server discovery task."""
def _discovered_server(server):
@ -110,8 +110,9 @@ async def start_server_discovery(hass):
hass.data.setdefault(DOMAIN, {})
if DISCOVERY_TASK not in hass.data[DOMAIN]:
_LOGGER.debug("Adding server discovery task for squeezebox")
hass.data[DOMAIN][DISCOVERY_TASK] = hass.async_create_task(
async_discover(_discovered_server)
hass.data[DOMAIN][DISCOVERY_TASK] = hass.async_create_background_task(
async_discover(_discovered_server),
name="squeezebox server discovery",
)

View File

@ -24,7 +24,6 @@ SCAN_INTERVAL = timedelta(minutes=1)
ATTR_LAST_UPDATED = "time_last_updated"
SIGNAL_UPDATE_ENTITY = "tellduslive_update"
TELLDUS_DISCOVERY_NEW = "telldus_new_{}_{}"
CLOUD_NAME = "Cloud API"

View File

@ -46,14 +46,14 @@ class TelldusLiveCover(TelldusLiveEntity, CoverEntity):
def close_cover(self, **kwargs: Any) -> None:
"""Close the cover."""
self.device.down()
self._update_callback()
self.schedule_update_ha_state()
def open_cover(self, **kwargs: Any) -> None:
"""Open the cover."""
self.device.up()
self._update_callback()
self.schedule_update_ha_state()
def stop_cover(self, **kwargs: Any) -> None:
"""Stop the cover."""
self.device.stop()
self._update_callback()
self.schedule_update_ha_state()

View File

@ -11,7 +11,6 @@ from homeassistant.const import (
ATTR_MODEL,
ATTR_VIA_DEVICE,
)
from homeassistant.core import callback
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity import Entity
@ -33,25 +32,16 @@ class TelldusLiveEntity(Entity):
"""Initialize the entity."""
self._id = device_id
self._client = client
self._async_unsub_dispatcher_connect = None
async def async_added_to_hass(self):
"""Call when entity is added to hass."""
_LOGGER.debug("Created device %s", self)
self._async_unsub_dispatcher_connect = async_dispatcher_connect(
self.hass, SIGNAL_UPDATE_ENTITY, self._update_callback
self.async_on_remove(
async_dispatcher_connect(
self.hass, SIGNAL_UPDATE_ENTITY, self.async_write_ha_state
)
)
async def async_will_remove_from_hass(self):
"""Disconnect dispatcher listener when removed."""
if self._async_unsub_dispatcher_connect:
self._async_unsub_dispatcher_connect()
@callback
def _update_callback(self):
"""Return the property of the device might have changed."""
self.async_write_ha_state()
@property
def device_id(self):
"""Return the id of the device."""

View File

@ -50,7 +50,7 @@ class TelldusLiveLight(TelldusLiveEntity, LightEntity):
def changed(self):
"""Define a property of the device that might have changed."""
self._last_brightness = self.brightness
self._update_callback()
self.schedule_update_ha_state()
@property
def brightness(self):

View File

@ -45,9 +45,9 @@ class TelldusLiveSwitch(TelldusLiveEntity, SwitchEntity):
def turn_on(self, **kwargs: Any) -> None:
"""Turn the switch on."""
self.device.turn_on()
self._update_callback()
self.schedule_update_ha_state()
def turn_off(self, **kwargs: Any) -> None:
"""Turn the switch off."""
self.device.turn_off()
self._update_callback()
self.schedule_update_ha_state()

View File

@ -7,6 +7,6 @@
"integration_type": "device",
"iot_class": "local_push",
"quality_scale": "platinum",
"requirements": ["wled==0.17.0"],
"requirements": ["wled==0.17.1"],
"zeroconf": ["_wled._tcp.local."]
}

View File

@ -41,7 +41,6 @@ from zwave_js_server.const.command_class.thermostat import (
THERMOSTAT_SETPOINT_PROPERTY,
)
from zwave_js_server.exceptions import UnknownValueData
from zwave_js_server.model.device_class import DeviceClassItem
from zwave_js_server.model.node import Node as ZwaveNode
from zwave_js_server.model.value import (
ConfigurationValue,
@ -1180,14 +1179,22 @@ def async_discover_single_value(
continue
# check device_class_generic
if value.node.device_class and not check_device_class(
value.node.device_class.generic, schema.device_class_generic
if schema.device_class_generic and (
not value.node.device_class
or not any(
value.node.device_class.generic.label == val
for val in schema.device_class_generic
)
):
continue
# check device_class_specific
if value.node.device_class and not check_device_class(
value.node.device_class.specific, schema.device_class_specific
if schema.device_class_specific and (
not value.node.device_class
or not any(
value.node.device_class.specific.label == val
for val in schema.device_class_specific
)
):
continue
@ -1379,15 +1386,3 @@ def check_value(value: ZwaveValue, schema: ZWaveValueDiscoverySchema) -> bool:
if schema.stateful is not None and value.metadata.stateful != schema.stateful:
return False
return True
@callback
def check_device_class(
device_class: DeviceClassItem, required_value: set[str] | None
) -> bool:
"""Check if device class id or label matches."""
if required_value is None:
return True
if any(device_class.label == val for val in required_value):
return True
return False

View File

@ -23,7 +23,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 5
PATCH_VERSION: Final = "3"
PATCH_VERSION: Final = "4"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)

View File

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

View File

@ -1649,7 +1649,7 @@ pyCEC==0.5.2
pyControl4==1.1.0
# homeassistant.components.duotecno
pyDuotecno==2024.3.2
pyDuotecno==2024.5.0
# homeassistant.components.electrasmart
pyElectra==1.2.0
@ -2439,7 +2439,7 @@ renault-api==0.2.2
renson-endura-delta==1.7.1
# homeassistant.components.reolink
reolink-aio==0.8.9
reolink-aio==0.8.10
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@ -2572,7 +2572,7 @@ smhi-pkg==1.0.16
snapcast==2.3.6
# homeassistant.components.sonos
soco==0.30.3
soco==0.30.4
# homeassistant.components.solaredge_local
solaredge-local==0.2.3
@ -2866,7 +2866,7 @@ wiffi==1.1.2
wirelesstagpy==0.8.1
# homeassistant.components.wled
wled==0.17.0
wled==0.17.1
# homeassistant.components.wolflink
wolf-comm==0.0.7

View File

@ -1305,7 +1305,7 @@ pyCEC==0.5.2
pyControl4==1.1.0
# homeassistant.components.duotecno
pyDuotecno==2024.3.2
pyDuotecno==2024.5.0
# homeassistant.components.electrasmart
pyElectra==1.2.0
@ -1897,7 +1897,7 @@ renault-api==0.2.2
renson-endura-delta==1.7.1
# homeassistant.components.reolink
reolink-aio==0.8.9
reolink-aio==0.8.10
# homeassistant.components.rflink
rflink==0.0.66
@ -1991,7 +1991,7 @@ smhi-pkg==1.0.16
snapcast==2.3.6
# homeassistant.components.sonos
soco==0.30.3
soco==0.30.4
# homeassistant.components.solax
solax==3.1.0
@ -2222,7 +2222,7 @@ whois==0.9.27
wiffi==1.1.2
# homeassistant.components.wled
wled==0.17.0
wled==0.17.1
# homeassistant.components.wolflink
wolf-comm==0.0.7

View File

@ -6,12 +6,12 @@ from unittest.mock import AsyncMock
import pytest
from python_homeassistant_analytics import HomeassistantAnalyticsConnectionError
from homeassistant import config_entries
from homeassistant.components.analytics_insights.const import (
CONF_TRACKED_CUSTOM_INTEGRATIONS,
CONF_TRACKED_INTEGRATIONS,
DOMAIN,
)
from homeassistant.config_entries import SOURCE_USER
from homeassistant.core import HomeAssistant
from homeassistant.data_entry_flow import FlowResultType
@ -61,7 +61,7 @@ async def test_form(
) -> None:
"""Test we get the form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
@ -96,7 +96,7 @@ async def test_submitting_empty_form(
) -> None:
"""Test we can't submit an empty form."""
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.FORM
@ -128,20 +128,28 @@ async def test_submitting_empty_form(
assert len(mock_setup_entry.mock_calls) == 1
@pytest.mark.parametrize(
("exception", "reason"),
[
(HomeassistantAnalyticsConnectionError, "cannot_connect"),
(Exception, "unknown"),
],
)
async def test_form_cannot_connect(
hass: HomeAssistant, mock_analytics_client: AsyncMock
hass: HomeAssistant,
mock_analytics_client: AsyncMock,
exception: Exception,
reason: str,
) -> None:
"""Test we handle cannot connect error."""
mock_analytics_client.get_integrations.side_effect = (
HomeassistantAnalyticsConnectionError
)
mock_analytics_client.get_integrations.side_effect = exception
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
assert result["reason"] == reason
async def test_form_already_configured(
@ -159,7 +167,7 @@ async def test_form_already_configured(
entry.add_to_hass(hass)
result = await hass.config_entries.flow.async_init(
DOMAIN, context={"source": config_entries.SOURCE_USER}
DOMAIN, context={"source": SOURCE_USER}
)
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "single_instance_allowed"

View File

@ -1522,4 +1522,4 @@ async def test_verify_group_color_mode_fallback(
)
group_state = hass.states.get("light.opbergruimte")
assert group_state.state == STATE_ON
assert group_state.attributes[ATTR_COLOR_MODE] is ColorMode.UNKNOWN
assert group_state.attributes[ATTR_COLOR_MODE] is ColorMode.BRIGHTNESS

View File

@ -71,6 +71,22 @@ async def test_services(
mock_fully_kiosk.setConfigurationString.assert_called_once_with(key, value)
key = "test_key"
value = 1234
await hass.services.async_call(
DOMAIN,
SERVICE_SET_CONFIG,
{
ATTR_DEVICE_ID: [device_entry.id],
ATTR_KEY: key,
ATTR_VALUE: value,
},
blocking=True,
)
mock_fully_kiosk.setConfigurationString.assert_called_with(key, str(value))
key = "test_key"
value = "true"
await hass.services.async_call(

View File

@ -95,29 +95,59 @@ async def test_default_prompt(
suggested_area="Test Area 2",
)
with patch("google.generativeai.GenerativeModel") as mock_model:
mock_model.return_value.start_chat.return_value = AsyncMock()
mock_chat = AsyncMock()
mock_model.return_value.start_chat.return_value = mock_chat
chat_response = MagicMock()
mock_chat.send_message_async.return_value = chat_response
chat_response.parts = ["Hi there!"]
chat_response.text = "Hi there!"
result = await conversation.async_converse(
hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id
)
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE
assert result.response.as_dict()["speech"]["plain"]["speech"] == "Hi there!"
assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot
async def test_error_handling(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component
) -> None:
"""Test that the default prompt works."""
"""Test that client errors are caught."""
with patch("google.generativeai.GenerativeModel") as mock_model:
mock_chat = AsyncMock()
mock_model.return_value.start_chat.return_value = mock_chat
mock_chat.send_message_async.side_effect = ClientError("")
mock_chat.send_message_async.side_effect = ClientError("some error")
result = await conversation.async_converse(
hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id
)
assert result.response.response_type == intent.IntentResponseType.ERROR, result
assert result.response.error_code == "unknown", result
assert result.response.as_dict()["speech"]["plain"]["speech"] == (
"Sorry, I had a problem talking to Google Generative AI: None some error"
)
async def test_blocked_response(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component
) -> None:
"""Test response was blocked."""
with patch("google.generativeai.GenerativeModel") as mock_model:
mock_chat = AsyncMock()
mock_model.return_value.start_chat.return_value = mock_chat
chat_response = MagicMock()
mock_chat.send_message_async.return_value = chat_response
chat_response.parts = []
result = await conversation.async_converse(
hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id
)
assert result.response.response_type == intent.IntentResponseType.ERROR, result
assert result.response.error_code == "unknown", result
assert result.response.as_dict()["speech"]["plain"]["speech"] == (
"Sorry, I had a problem talking to Google Generative AI. Likely blocked"
)
async def test_template_error(

View File

@ -22,7 +22,7 @@ from tests.common import MockConfigEntry, load_fixture, load_json_value_fixture
@pytest.fixture(name="jwt")
def load_jwt_fixture():
def load_jwt_fixture() -> str:
"""Load Fixture data."""
return load_fixture("jwt", DOMAIN)
@ -33,8 +33,14 @@ def mock_expires_at() -> float:
return time.time() + 3600
@pytest.fixture(name="scope")
def mock_scope() -> str:
"""Fixture to set correct scope for the token."""
return "iam:read amc:api"
@pytest.fixture
def mock_config_entry(jwt, expires_at: int) -> MockConfigEntry:
def mock_config_entry(jwt: str, expires_at: int, scope: str) -> MockConfigEntry:
"""Return the default mocked config entry."""
return MockConfigEntry(
version=1,
@ -44,7 +50,7 @@ def mock_config_entry(jwt, expires_at: int) -> MockConfigEntry:
"auth_implementation": DOMAIN,
"token": {
"access_token": jwt,
"scope": "iam:read amc:api",
"scope": scope,
"expires_in": 86399,
"refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f",
"provider": "husqvarna",

View File

@ -2,6 +2,8 @@
from unittest.mock import AsyncMock, patch
import pytest
from homeassistant import config_entries
from homeassistant.components.husqvarna_automower.const import (
DOMAIN,
@ -21,12 +23,21 @@ from tests.test_util.aiohttp import AiohttpClientMocker
from tests.typing import ClientSessionGenerator
@pytest.mark.parametrize(
("new_scope", "amount"),
[
("iam:read amc:api", 1),
("iam:read", 0),
],
)
async def test_full_flow(
hass: HomeAssistant,
hass_client_no_auth,
aioclient_mock: AiohttpClientMocker,
current_request_with_host,
jwt,
jwt: str,
new_scope: str,
amount: int,
) -> None:
"""Check full flow."""
result = await hass.config_entries.flow.async_init(
@ -56,7 +67,7 @@ async def test_full_flow(
OAUTH2_TOKEN,
json={
"access_token": jwt,
"scope": "iam:read amc:api",
"scope": new_scope,
"expires_in": 86399,
"refresh_token": "mock-refresh-token",
"provider": "husqvarna",
@ -72,8 +83,8 @@ async def test_full_flow(
) as mock_setup:
await hass.config_entries.flow.async_configure(result["flow_id"])
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert len(mock_setup.mock_calls) == 1
assert len(hass.config_entries.async_entries(DOMAIN)) == amount
assert len(mock_setup.mock_calls) == amount
async def test_config_non_unique_profile(
@ -129,6 +140,14 @@ async def test_config_non_unique_profile(
assert result["reason"] == "already_configured"
@pytest.mark.parametrize(
("scope", "step_id", "reason", "new_scope"),
[
("iam:read amc:api", "reauth_confirm", "reauth_successful", "iam:read amc:api"),
("iam:read", "missing_scope", "reauth_successful", "iam:read amc:api"),
("iam:read", "missing_scope", "missing_amc_scope", "iam:read"),
],
)
async def test_reauth(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
@ -136,7 +155,10 @@ async def test_reauth(
mock_config_entry: MockConfigEntry,
current_request_with_host: None,
mock_automower_client: AsyncMock,
jwt,
jwt: str,
step_id: str,
new_scope: str,
reason: str,
) -> None:
"""Test the reauthentication case updates the existing config entry."""
@ -148,7 +170,7 @@ async def test_reauth(
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
result = flows[0]
assert result["step_id"] == "reauth_confirm"
assert result["step_id"] == step_id
result = await hass.config_entries.flow.async_configure(result["flow_id"], {})
state = config_entry_oauth2_flow._encode_jwt(
@ -172,7 +194,7 @@ async def test_reauth(
OAUTH2_TOKEN,
json={
"access_token": "mock-updated-token",
"scope": "iam:read amc:api",
"scope": new_scope,
"expires_in": 86399,
"refresh_token": "mock-refresh-token",
"provider": "husqvarna",
@ -191,7 +213,7 @@ async def test_reauth(
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "reauth_successful"
assert result.get("reason") == reason
assert mock_config_entry.unique_id == USER_ID
assert "token" in mock_config_entry.data
@ -200,6 +222,12 @@ async def test_reauth(
assert mock_config_entry.data["token"].get("refresh_token") == "mock-refresh-token"
@pytest.mark.parametrize(
("user_id", "reason"),
[
("wrong_user_id", "wrong_account"),
],
)
async def test_reauth_wrong_account(
hass: HomeAssistant,
hass_client_no_auth: ClientSessionGenerator,
@ -208,6 +236,9 @@ async def test_reauth_wrong_account(
current_request_with_host: None,
mock_automower_client: AsyncMock,
jwt,
user_id: str,
reason: str,
scope: str,
) -> None:
"""Test the reauthentication aborts, if user tries to reauthenticate with another account."""
@ -247,7 +278,7 @@ async def test_reauth_wrong_account(
"expires_in": 86399,
"refresh_token": "mock-refresh-token",
"provider": "husqvarna",
"user_id": "wrong-user-id",
"user_id": user_id,
"token_type": "Bearer",
"expires_at": 1697753347,
},
@ -262,7 +293,7 @@ async def test_reauth_wrong_account(
assert len(hass.config_entries.async_entries(DOMAIN)) == 1
assert result.get("type") is FlowResultType.ABORT
assert result.get("reason") == "wrong_account"
assert result.get("reason") == reason
assert mock_config_entry.unique_id == USER_ID
assert "token" in mock_config_entry.data

View File

@ -5,7 +5,11 @@ import http
import time
from unittest.mock import AsyncMock
from aioautomower.exceptions import ApiException, HusqvarnaWSServerHandshakeError
from aioautomower.exceptions import (
ApiException,
AuthException,
HusqvarnaWSServerHandshakeError,
)
from freezegun.api import FrozenDateTimeFactory
import pytest
from syrupy.assertion import SnapshotAssertion
@ -39,6 +43,26 @@ async def test_load_unload_entry(
assert entry.state is ConfigEntryState.NOT_LOADED
@pytest.mark.parametrize(
("scope"),
[
("iam:read"),
],
)
async def test_load_missing_scope(
hass: HomeAssistant,
mock_automower_client: AsyncMock,
mock_config_entry: MockConfigEntry,
) -> None:
"""Test if the entry starts a reauth with the missing token scope."""
await setup_integration(hass, mock_config_entry)
assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR
flows = hass.config_entries.flow.async_progress()
assert len(flows) == 1
result = flows[0]
assert result["step_id"] == "missing_scope"
@pytest.mark.parametrize(
("expires_at", "status", "expected_state"),
[
@ -75,19 +99,25 @@ async def test_expired_token_refresh_failure(
assert mock_config_entry.state is expected_state
@pytest.mark.parametrize(
("exception", "entry_state"),
[
(ApiException, ConfigEntryState.SETUP_RETRY),
(AuthException, ConfigEntryState.SETUP_ERROR),
],
)
async def test_update_failed(
hass: HomeAssistant,
mock_automower_client: AsyncMock,
mock_config_entry: MockConfigEntry,
exception: Exception,
entry_state: ConfigEntryState,
) -> None:
"""Test load and unload entry."""
getattr(mock_automower_client, "get_status").side_effect = ApiException(
"Test error"
)
"""Test update failed."""
mock_automower_client.get_status.side_effect = exception("Test error")
await setup_integration(hass, mock_config_entry)
entry = hass.config_entries.async_entries(DOMAIN)[0]
assert entry.state is ConfigEntryState.SETUP_RETRY
assert entry.state is entry_state
async def test_websocket_not_available(

View File

@ -4382,6 +4382,34 @@ async def test_server_sock_connect_and_disconnect(
assert len(calls) == 0
@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0)
@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0)
@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0)
async def test_server_sock_buffer_size(
hass: HomeAssistant,
mqtt_client_mock: MqttMockPahoClient,
mqtt_mock_entry: MqttMockHAClientGenerator,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test handling the socket buffer size fails."""
mqtt_mock = await mqtt_mock_entry()
await hass.async_block_till_done()
assert mqtt_mock.connected is True
mqtt_client_mock.loop_misc.return_value = paho_mqtt.MQTT_ERR_SUCCESS
client, server = socket.socketpair(
family=socket.AF_UNIX, type=socket.SOCK_STREAM, proto=0
)
client.setblocking(False)
server.setblocking(False)
with patch.object(client, "setsockopt", side_effect=OSError("foo")):
mqtt_client_mock.on_socket_open(mqtt_client_mock, None, client)
mqtt_client_mock.on_socket_register_write(mqtt_client_mock, None, client)
await hass.async_block_till_done()
assert "Unable to increase the socket buffer size" in caplog.text
@patch("homeassistant.components.mqtt.client.INITIAL_SUBSCRIBE_COOLDOWN", 0.0)
@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0)
@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0)

View File

@ -1,7 +1,7 @@
"""Test pi_hole component."""
import logging
from unittest.mock import AsyncMock
from unittest.mock import ANY, AsyncMock
from hole.exceptions import HoleError
import pytest
@ -12,12 +12,20 @@ from homeassistant.components.pi_hole.const import (
SERVICE_DISABLE,
SERVICE_DISABLE_ATTR_DURATION,
)
from homeassistant.const import ATTR_ENTITY_ID, CONF_HOST, CONF_NAME
from homeassistant.const import (
ATTR_ENTITY_ID,
CONF_HOST,
CONF_LOCATION,
CONF_NAME,
CONF_SSL,
)
from homeassistant.core import HomeAssistant
from . import (
API_KEY,
CONFIG_DATA,
CONFIG_DATA_DEFAULTS,
CONFIG_ENTRY_WITHOUT_API_KEY,
SWITCH_ENTITY_ID,
_create_mocked_hole,
_patch_init_hole,
@ -26,6 +34,29 @@ from . import (
from tests.common import MockConfigEntry
@pytest.mark.parametrize(
("config_entry_data", "expected_api_token"),
[(CONFIG_DATA_DEFAULTS, API_KEY), (CONFIG_ENTRY_WITHOUT_API_KEY, "")],
)
async def test_setup_api(
hass: HomeAssistant, config_entry_data: dict, expected_api_token: str
) -> None:
"""Tests the API object is created with the expected parameters."""
mocked_hole = _create_mocked_hole()
config_entry_data = {**config_entry_data, CONF_STATISTICS_ONLY: True}
entry = MockConfigEntry(domain=pi_hole.DOMAIN, data=config_entry_data)
entry.add_to_hass(hass)
with _patch_init_hole(mocked_hole) as patched_init_hole:
assert await hass.config_entries.async_setup(entry.entry_id)
patched_init_hole.assert_called_once_with(
config_entry_data[CONF_HOST],
ANY,
api_token=expected_api_token,
location=config_entry_data[CONF_LOCATION],
tls=config_entry_data[CONF_SSL],
)
async def test_setup_with_defaults(hass: HomeAssistant) -> None:
"""Tests component setup with default config."""
mocked_hole = _create_mocked_hole()

View File

@ -5,7 +5,7 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from reolink_aio.exceptions import ReolinkError
from reolink_aio.exceptions import CredentialsInvalidError, ReolinkError
from homeassistant.components.reolink import FIRMWARE_UPDATE_INTERVAL, const
from homeassistant.config import async_process_ha_core_config
@ -50,6 +50,11 @@ pytestmark = pytest.mark.usefixtures("reolink_connect", "reolink_platforms")
AsyncMock(side_effect=ReolinkError("Test error")),
ConfigEntryState.SETUP_RETRY,
),
(
"get_states",
AsyncMock(side_effect=CredentialsInvalidError("Test error")),
ConfigEntryState.SETUP_ERROR,
),
(
"supported",
Mock(return_value=False),

View File

@ -675,6 +675,12 @@ def central_scene_node_state_fixture():
return json.loads(load_fixture("zwave_js/central_scene_node_state.json"))
@pytest.fixture(name="light_device_class_is_null_state", scope="package")
def light_device_class_is_null_state_fixture():
"""Load node with device class is None state fixture data."""
return json.loads(load_fixture("zwave_js/light_device_class_is_null_state.json"))
# model fixtures
@ -1325,3 +1331,11 @@ def central_scene_node_fixture(client, central_scene_node_state):
node = Node(client, copy.deepcopy(central_scene_node_state))
client.driver.controller.nodes[node.node_id] = node
return node
@pytest.fixture(name="light_device_class_is_null")
def light_device_class_is_null_fixture(client, light_device_class_is_null_state):
"""Mock a node when device class is null."""
node = Node(client, copy.deepcopy(light_device_class_is_null_state))
client.driver.controller.nodes[node.node_id] = node
return node

File diff suppressed because it is too large Load Diff

View File

@ -305,3 +305,15 @@ async def test_indicator_test(
"propertyKey": "Switch",
}
assert args["value"] is False
async def test_light_device_class_is_null(
hass: HomeAssistant, client, light_device_class_is_null, integration
) -> None:
"""Test that a Multilevel Switch CC value with a null device class is discovered as a light.
Tied to #117121.
"""
node = light_device_class_is_null
assert node.device_class is None
assert hass.states.get("light.bar_display_cases")