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: except HomeassistantAnalyticsConnectionError:
LOGGER.exception("Error connecting to Home Assistant analytics") LOGGER.exception("Error connecting to Home Assistant analytics")
return self.async_abort(reason="cannot_connect") return self.async_abort(reason="cannot_connect")
except Exception: # pylint: disable=broad-except
LOGGER.exception("Unexpected error")
return self.async_abort(reason="unknown")
options = [ options = [
SelectOptionDict( SelectOptionDict(

View File

@ -13,7 +13,8 @@
} }
}, },
"abort": { "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": { "error": {
"no_integration_selected": "You must select at least one integration to track" "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.""" """Implementation of the base Aurora Entity."""
_attr_attribution = ATTRIBUTION _attr_attribution = ATTRIBUTION
_attr_has_entity_name = True
def __init__( def __init__(
self, self,

View File

@ -2,13 +2,13 @@
from __future__ import annotations 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.groups import GroupHandler
from pydeconz.interfaces.lights import LightHandler from pydeconz.interfaces.lights import LightHandler
from pydeconz.models import ResourceType from pydeconz.models import ResourceType
from pydeconz.models.event import EventType 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 pydeconz.models.light.light import Light, LightAlert, LightColorMode, LightEffect
from homeassistant.components.light import ( from homeassistant.components.light import (
@ -105,6 +105,23 @@ class SetStateAttributes(TypedDict, total=False):
xy: tuple[float, float] 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( async def async_setup_entry(
hass: HomeAssistant, hass: HomeAssistant,
config_entry: ConfigEntry, config_entry: ConfigEntry,
@ -148,11 +165,12 @@ async def async_setup_entry(
if (group := hub.api.groups[group_id]) and not group.lights: if (group := hub.api.groups[group_id]) and not group.lights:
return return
first = True lights = [
for light_id in group.lights: light
if (light := hub.api.lights.lights.get(light_id)) and light.reachable: for light_id in group.lights
group.update_color_state(light, update_all_attributes=first) if (light := hub.api.lights.lights.get(light_id)) and light.reachable
first = False ]
update_color_state(group, lights, True)
async_add_entities([DeconzGroup(group, hub)]) 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: if self._device.reachable and "attr" not in self._device.changed_keys:
for group in self.hub.api.groups.values(): for group in self.hub.api.groups.values():
if self._device.resource_id in group.lights: if self._device.resource_id in group.lights:
group.update_color_state(self._device) update_color_state(group, [self._device])
class DeconzGroup(DeconzBaseLight[Group]): class DeconzGroup(DeconzBaseLight[Group]):

View File

@ -41,6 +41,11 @@ class DuotecnoEntity(Entity):
"""When a unit has an update.""" """When a unit has an update."""
self.async_write_ha_state() 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") _T = TypeVar("_T", bound="DuotecnoEntity")
_P = ParamSpec("_P") _P = ParamSpec("_P")

View File

@ -7,5 +7,5 @@
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"], "loggers": ["pyduotecno", "pyduotecno-node", "pyduotecno-unit"],
"quality_scale": "silver", "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: async def async_set_config(call: ServiceCall) -> None:
"""Set a Fully Kiosk Browser config value on the device.""" """Set a Fully Kiosk Browser config value on the device."""
for coordinator in await collect_coordinators(call.data[ATTR_DEVICE_ID]): 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. # Fully API has different methods for setting string and bool values.
# check if call.data[ATTR_VALUE] is a bool # check if call.data[ATTR_VALUE] is a bool
if isinstance(call.data[ATTR_VALUE], bool) or call.data[ if isinstance(value, bool) or (
ATTR_VALUE isinstance(value, str) and value.lower() in ("true", "false")
].lower() in ("true", "false"): ):
await coordinator.fully.setConfigurationBool( await coordinator.fully.setConfigurationBool(key, value)
call.data[ATTR_KEY], call.data[ATTR_VALUE]
)
else: else:
await coordinator.fully.setConfigurationString( # Convert any int values to string
call.data[ATTR_KEY], call.data[ATTR_VALUE] if isinstance(value, int):
) value = str(value)
await coordinator.fully.setConfigurationString(key, value)
# Register all the above services # Register all the above services
service_mapping = [ 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_DEVICE_ID): cv.ensure_list,
vol.Required(ATTR_KEY): cv.string, 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.language = user_input.language
self.assistant = TextAssistant(credentials, self.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>" text_response = resp[0] or "<empty response>"
intent_response = intent.IntentResponse(language=user_input.language) intent_response = intent.IntentResponse(language=user_input.language)

View File

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

View File

@ -182,11 +182,11 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent):
conversation_id = ulid.ulid_now() conversation_id = ulid.ulid_now()
messages = [{}, {}] messages = [{}, {}]
intent_response = intent.IntentResponse(language=user_input.language)
try: try:
prompt = self._async_generate_prompt(raw_prompt) prompt = self._async_generate_prompt(raw_prompt)
except TemplateError as err: except TemplateError as err:
_LOGGER.error("Error rendering prompt: %s", err) _LOGGER.error("Error rendering prompt: %s", err)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error( intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN, intent.IntentResponseErrorCode.UNKNOWN,
f"Sorry, I had a problem with my template: {err}", f"Sorry, I had a problem with my template: {err}",
@ -210,7 +210,6 @@ class GoogleGenerativeAIAgent(conversation.AbstractConversationAgent):
genai_types.StopCandidateException, genai_types.StopCandidateException,
) as err: ) as err:
_LOGGER.error("Error sending message: %s", err) _LOGGER.error("Error sending message: %s", err)
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_error( intent_response.async_set_error(
intent.IntentResponseErrorCode.UNKNOWN, intent.IntentResponseErrorCode.UNKNOWN,
f"Sorry, I had a problem talking to Google Generative AI: {err}", 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) _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 self.history[conversation_id] = chat.history
intent_response = intent.IntentResponse(language=user_input.language)
intent_response.async_set_speech(chat_response.text) intent_response.async_set_speech(chat_response.text)
return conversation.ConversationResult( return conversation.ConversationResult(
response=intent_response, conversation_id=conversation_id 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 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) await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True return True

View File

@ -13,7 +13,9 @@ from homeassistant.helpers import config_entry_oauth2_flow
from .const import DOMAIN, NAME from .const import DOMAIN, NAME
_LOGGER = logging.getLogger(__name__) _LOGGER = logging.getLogger(__name__)
CONF_USER_ID = "user_id" CONF_USER_ID = "user_id"
HUSQVARNA_DEV_PORTAL_URL = "https://developer.husqvarnagroup.cloud/applications"
class HusqvarnaConfigFlowHandler( class HusqvarnaConfigFlowHandler(
@ -29,8 +31,14 @@ class HusqvarnaConfigFlowHandler(
async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult:
"""Create an entry for the flow.""" """Create an entry for the flow."""
token = data[CONF_TOKEN] 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] user_id = token[CONF_USER_ID]
if self.reauth_entry: 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: if self.reauth_entry.unique_id != user_id:
return self.async_abort(reason="wrong_account") return self.async_abort(reason="wrong_account")
return self.async_update_reload_and_abort(self.reauth_entry, data=data) 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.reauth_entry = self.hass.config_entries.async_get_entry(
self.context["entry_id"] 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() return await self.async_step_reauth_confirm()
async def async_step_reauth_confirm( async def async_step_reauth_confirm(
@ -65,3 +76,19 @@ class HusqvarnaConfigFlowHandler(
if user_input is None: if user_input is None:
return self.async_show_form(step_id="reauth_confirm") return self.async_show_form(step_id="reauth_confirm")
return await self.async_step_user() 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 from datetime import timedelta
import logging import logging
from aioautomower.exceptions import ApiException, HusqvarnaWSServerHandshakeError from aioautomower.exceptions import (
ApiException,
AuthException,
HusqvarnaWSServerHandshakeError,
)
from aioautomower.model import MowerAttributes from aioautomower.model import MowerAttributes
from aioautomower.session import AutomowerSession from aioautomower.session import AutomowerSession
from homeassistant.config_entries import ConfigEntry from homeassistant.config_entries import ConfigEntry
from homeassistant.core import HomeAssistant, callback from homeassistant.core import HomeAssistant, callback
from homeassistant.exceptions import ConfigEntryAuthFailed
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
from .const import DOMAIN from .const import DOMAIN
@ -46,6 +51,8 @@ class AutomowerDataUpdateCoordinator(DataUpdateCoordinator[dict[str, MowerAttrib
return await self.api.get_status() return await self.api.get_status()
except ApiException as err: except ApiException as err:
raise UpdateFailed(err) from err raise UpdateFailed(err) from err
except AuthException as err:
raise ConfigEntryAuthFailed(err) from err
@callback @callback
def callback(self, ws_data: dict[str, MowerAttributes]) -> None: def callback(self, ws_data: dict[str, MowerAttributes]) -> None:

View File

@ -5,6 +5,10 @@
"title": "[%key:common::config_flow::title::reauth%]", "title": "[%key:common::config_flow::title::reauth%]",
"description": "The Husqvarna Automower integration needs to re-authenticate your account" "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": { "pick_implementation": {
"title": "[%key:common::config_flow::title::oauth2_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_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]",
"oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]",
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "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": { "create_entry": {
"default": "[%key:common::config_flow::create_entry::authenticated%]" "default": "[%key:common::config_flow::create_entry::authenticated%]"

View File

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

View File

@ -480,7 +480,13 @@ class KodiEntity(MediaPlayerEntity):
self._reset_state() self._reset_state()
return 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: if self._kodi_is_off:
self._reset_state() self._reset_state()

View File

@ -96,7 +96,7 @@ class LutronCasetaTiltOnlyBlind(LutronCasetaDeviceUpdatableEntity, CoverEntity):
async def async_set_cover_tilt_position(self, **kwargs: Any) -> None: async def async_set_cover_tilt_position(self, **kwargs: Any) -> None:
"""Move the blind to a specific tilt.""" """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 = { PYLUTRON_TYPE_TO_CLASSES = {

View File

@ -83,8 +83,18 @@ if TYPE_CHECKING:
_LOGGER = logging.getLogger(__name__) _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 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 SUBSCRIBE_COOLDOWN = 0.1
UNSUBSCRIBE_COOLDOWN = 0.1 UNSUBSCRIBE_COOLDOWN = 0.1
TIMEOUT_ACK = 10 TIMEOUT_ACK = 10
@ -429,6 +439,7 @@ class MQTT:
hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self._async_ha_stop), hass.bus.async_listen(EVENT_HOMEASSISTANT_STOP, self._async_ha_stop),
) )
) )
self._socket_buffersize: int | None = None
@callback @callback
def _async_ha_started(self, _hass: HomeAssistant) -> None: def _async_ha_started(self, _hass: HomeAssistant) -> None:
@ -529,6 +540,29 @@ class MQTT:
self.hass, self._misc_loop(), name="mqtt misc loop" 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( def _on_socket_open(
self, client: mqtt.Client, userdata: Any, sock: SocketType self, client: mqtt.Client, userdata: Any, sock: SocketType
) -> None: ) -> None:
@ -545,6 +579,7 @@ class MQTT:
fileno = sock.fileno() fileno = sock.fileno()
_LOGGER.debug("%s: connection opened %s", self.config_entry.title, fileno) _LOGGER.debug("%s: connection opened %s", self.config_entry.title, fileno)
if fileno > -1: if fileno > -1:
self._increase_socket_buffer_size(sock)
self.loop.add_reader(sock, partial(self._async_reader_callback, client)) self.loop.add_reader(sock, partial(self._async_reader_callback, client))
self._async_start_misc_loop() self._async_start_misc_loop()

View File

@ -24,7 +24,7 @@ class OndiloIcoCoordinator(DataUpdateCoordinator[list[dict[str, Any]]]):
hass, hass,
logger=_LOGGER, logger=_LOGGER,
name=DOMAIN, name=DOMAIN,
update_interval=timedelta(minutes=5), update_interval=timedelta(minutes=20),
) )
self.api = api 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] use_tls = entry.data[CONF_SSL]
verify_tls = entry.data[CONF_VERIFY_SSL] verify_tls = entry.data[CONF_VERIFY_SSL]
location = entry.data[CONF_LOCATION] 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 # remove obsolet CONF_STATISTICS_ONLY from entry.data
if CONF_STATISTICS_ONLY in entry.data: if CONF_STATISTICS_ONLY in entry.data:

View File

@ -1,9 +1,10 @@
"""Base entity for poolsense integration.""" """Base entity for poolsense integration."""
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.entity import EntityDescription from homeassistant.helpers.entity import EntityDescription
from homeassistant.helpers.update_coordinator import CoordinatorEntity from homeassistant.helpers.update_coordinator import CoordinatorEntity
from .const import ATTRIBUTION from .const import ATTRIBUTION, DOMAIN
from .coordinator import PoolSenseDataUpdateCoordinator from .coordinator import PoolSenseDataUpdateCoordinator
@ -11,6 +12,7 @@ class PoolSenseEntity(CoordinatorEntity[PoolSenseDataUpdateCoordinator]):
"""Implements a common class elements representing the PoolSense component.""" """Implements a common class elements representing the PoolSense component."""
_attr_attribution = ATTRIBUTION _attr_attribution = ATTRIBUTION
_attr_has_entity_name = True
def __init__( def __init__(
self, self,
@ -21,5 +23,8 @@ class PoolSenseEntity(CoordinatorEntity[PoolSenseDataUpdateCoordinator]):
"""Initialize poolsense sensor.""" """Initialize poolsense sensor."""
super().__init__(coordinator) super().__init__(coordinator)
self.entity_description = description self.entity_description = description
self._attr_name = f"PoolSense {description.name}"
self._attr_unique_id = f"{email}-{description.key}" 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: try:
await host.update_states() await host.update_states()
except CredentialsInvalidError as err: except CredentialsInvalidError as err:
await host.stop()
raise ConfigEntryAuthFailed(err) from err raise ConfigEntryAuthFailed(err) from err
except ReolinkError as err: except ReolinkError as err:
raise UpdateFailed(str(err)) from err raise UpdateFailed(str(err)) from err

View File

@ -18,5 +18,5 @@
"documentation": "https://www.home-assistant.io/integrations/reolink", "documentation": "https://www.home-assistant.io/integrations/reolink",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["reolink_aio"], "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", "documentation": "https://www.home-assistant.io/integrations/sonos",
"iot_class": "local_push", "iot_class": "local_push",
"loggers": ["soco"], "loggers": ["soco"],
"requirements": ["soco==0.30.3", "sonos-websocket==0.1.3"], "requirements": ["soco==0.30.4", "sonos-websocket==0.1.3"],
"ssdp": [ "ssdp": [
{ {
"st": "urn:schemas-upnp-org:device:ZonePlayer:1" "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.""" """Start a server discovery task."""
def _discovered_server(server): def _discovered_server(server):
@ -110,8 +110,9 @@ async def start_server_discovery(hass):
hass.data.setdefault(DOMAIN, {}) hass.data.setdefault(DOMAIN, {})
if DISCOVERY_TASK not in hass.data[DOMAIN]: if DISCOVERY_TASK not in hass.data[DOMAIN]:
_LOGGER.debug("Adding server discovery task for squeezebox") _LOGGER.debug("Adding server discovery task for squeezebox")
hass.data[DOMAIN][DISCOVERY_TASK] = hass.async_create_task( hass.data[DOMAIN][DISCOVERY_TASK] = hass.async_create_background_task(
async_discover(_discovered_server) 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" ATTR_LAST_UPDATED = "time_last_updated"
SIGNAL_UPDATE_ENTITY = "tellduslive_update"
TELLDUS_DISCOVERY_NEW = "telldus_new_{}_{}" TELLDUS_DISCOVERY_NEW = "telldus_new_{}_{}"
CLOUD_NAME = "Cloud API" CLOUD_NAME = "Cloud API"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -41,7 +41,6 @@ from zwave_js_server.const.command_class.thermostat import (
THERMOSTAT_SETPOINT_PROPERTY, THERMOSTAT_SETPOINT_PROPERTY,
) )
from zwave_js_server.exceptions import UnknownValueData 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.node import Node as ZwaveNode
from zwave_js_server.model.value import ( from zwave_js_server.model.value import (
ConfigurationValue, ConfigurationValue,
@ -1180,14 +1179,22 @@ def async_discover_single_value(
continue continue
# check device_class_generic # check device_class_generic
if value.node.device_class and not check_device_class( if schema.device_class_generic and (
value.node.device_class.generic, schema.device_class_generic not value.node.device_class
or not any(
value.node.device_class.generic.label == val
for val in schema.device_class_generic
)
): ):
continue continue
# check device_class_specific # check device_class_specific
if value.node.device_class and not check_device_class( if schema.device_class_specific and (
value.node.device_class.specific, schema.device_class_specific not value.node.device_class
or not any(
value.node.device_class.specific.label == val
for val in schema.device_class_specific
)
): ):
continue 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: if schema.stateful is not None and value.metadata.stateful != schema.stateful:
return False return False
return True 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" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024 MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 5 MINOR_VERSION: Final = 5
PATCH_VERSION: Final = "3" PATCH_VERSION: Final = "4"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}" __short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}" __version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0) REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 12, 0)

View File

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

View File

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

View File

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

View File

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

View File

@ -95,29 +95,59 @@ async def test_default_prompt(
suggested_area="Test Area 2", suggested_area="Test Area 2",
) )
with patch("google.generativeai.GenerativeModel") as mock_model: 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( result = await conversation.async_converse(
hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id
) )
assert result.response.response_type == intent.IntentResponseType.ACTION_DONE 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 assert [tuple(mock_call) for mock_call in mock_model.mock_calls] == snapshot
async def test_error_handling( async def test_error_handling(
hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component hass: HomeAssistant, mock_config_entry: MockConfigEntry, mock_init_component
) -> None: ) -> None:
"""Test that the default prompt works.""" """Test that client errors are caught."""
with patch("google.generativeai.GenerativeModel") as mock_model: with patch("google.generativeai.GenerativeModel") as mock_model:
mock_chat = AsyncMock() mock_chat = AsyncMock()
mock_model.return_value.start_chat.return_value = mock_chat 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( result = await conversation.async_converse(
hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id hass, "hello", None, Context(), agent_id=mock_config_entry.entry_id
) )
assert result.response.response_type == intent.IntentResponseType.ERROR, result assert result.response.response_type == intent.IntentResponseType.ERROR, result
assert result.response.error_code == "unknown", 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( 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") @pytest.fixture(name="jwt")
def load_jwt_fixture(): def load_jwt_fixture() -> str:
"""Load Fixture data.""" """Load Fixture data."""
return load_fixture("jwt", DOMAIN) return load_fixture("jwt", DOMAIN)
@ -33,8 +33,14 @@ def mock_expires_at() -> float:
return time.time() + 3600 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 @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 the default mocked config entry."""
return MockConfigEntry( return MockConfigEntry(
version=1, version=1,
@ -44,7 +50,7 @@ def mock_config_entry(jwt, expires_at: int) -> MockConfigEntry:
"auth_implementation": DOMAIN, "auth_implementation": DOMAIN,
"token": { "token": {
"access_token": jwt, "access_token": jwt,
"scope": "iam:read amc:api", "scope": scope,
"expires_in": 86399, "expires_in": 86399,
"refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f", "refresh_token": "3012bc9f-7a65-4240-b817-9154ffdcc30f",
"provider": "husqvarna", "provider": "husqvarna",

View File

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

View File

@ -5,7 +5,11 @@ import http
import time import time
from unittest.mock import AsyncMock from unittest.mock import AsyncMock
from aioautomower.exceptions import ApiException, HusqvarnaWSServerHandshakeError from aioautomower.exceptions import (
ApiException,
AuthException,
HusqvarnaWSServerHandshakeError,
)
from freezegun.api import FrozenDateTimeFactory from freezegun.api import FrozenDateTimeFactory
import pytest import pytest
from syrupy.assertion import SnapshotAssertion from syrupy.assertion import SnapshotAssertion
@ -39,6 +43,26 @@ async def test_load_unload_entry(
assert entry.state is ConfigEntryState.NOT_LOADED 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( @pytest.mark.parametrize(
("expires_at", "status", "expected_state"), ("expires_at", "status", "expected_state"),
[ [
@ -75,19 +99,25 @@ async def test_expired_token_refresh_failure(
assert mock_config_entry.state is expected_state 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( async def test_update_failed(
hass: HomeAssistant, hass: HomeAssistant,
mock_automower_client: AsyncMock, mock_automower_client: AsyncMock,
mock_config_entry: MockConfigEntry, mock_config_entry: MockConfigEntry,
exception: Exception,
entry_state: ConfigEntryState,
) -> None: ) -> None:
"""Test load and unload entry.""" """Test update failed."""
getattr(mock_automower_client, "get_status").side_effect = ApiException( mock_automower_client.get_status.side_effect = exception("Test error")
"Test error"
)
await setup_integration(hass, mock_config_entry) await setup_integration(hass, mock_config_entry)
entry = hass.config_entries.async_entries(DOMAIN)[0] entry = hass.config_entries.async_entries(DOMAIN)[0]
assert entry.state is entry_state
assert entry.state is ConfigEntryState.SETUP_RETRY
async def test_websocket_not_available( async def test_websocket_not_available(

View File

@ -4382,6 +4382,34 @@ async def test_server_sock_connect_and_disconnect(
assert len(calls) == 0 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.INITIAL_SUBSCRIBE_COOLDOWN", 0.0)
@patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.DISCOVERY_COOLDOWN", 0.0)
@patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0) @patch("homeassistant.components.mqtt.client.SUBSCRIBE_COOLDOWN", 0.0)

View File

@ -1,7 +1,7 @@
"""Test pi_hole component.""" """Test pi_hole component."""
import logging import logging
from unittest.mock import AsyncMock from unittest.mock import ANY, AsyncMock
from hole.exceptions import HoleError from hole.exceptions import HoleError
import pytest import pytest
@ -12,12 +12,20 @@ from homeassistant.components.pi_hole.const import (
SERVICE_DISABLE, SERVICE_DISABLE,
SERVICE_DISABLE_ATTR_DURATION, 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 homeassistant.core import HomeAssistant
from . import ( from . import (
API_KEY,
CONFIG_DATA, CONFIG_DATA,
CONFIG_DATA_DEFAULTS, CONFIG_DATA_DEFAULTS,
CONFIG_ENTRY_WITHOUT_API_KEY,
SWITCH_ENTITY_ID, SWITCH_ENTITY_ID,
_create_mocked_hole, _create_mocked_hole,
_patch_init_hole, _patch_init_hole,
@ -26,6 +34,29 @@ from . import (
from tests.common import MockConfigEntry 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: async def test_setup_with_defaults(hass: HomeAssistant) -> None:
"""Tests component setup with default config.""" """Tests component setup with default config."""
mocked_hole = _create_mocked_hole() mocked_hole = _create_mocked_hole()

View File

@ -5,7 +5,7 @@ from typing import Any
from unittest.mock import AsyncMock, MagicMock, Mock, patch from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest 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.components.reolink import FIRMWARE_UPDATE_INTERVAL, const
from homeassistant.config import async_process_ha_core_config 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")), AsyncMock(side_effect=ReolinkError("Test error")),
ConfigEntryState.SETUP_RETRY, ConfigEntryState.SETUP_RETRY,
), ),
(
"get_states",
AsyncMock(side_effect=CredentialsInvalidError("Test error")),
ConfigEntryState.SETUP_ERROR,
),
( (
"supported", "supported",
Mock(return_value=False), 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")) 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 # 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)) node = Node(client, copy.deepcopy(central_scene_node_state))
client.driver.controller.nodes[node.node_id] = node client.driver.controller.nodes[node.node_id] = node
return 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", "propertyKey": "Switch",
} }
assert args["value"] is False 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")