This commit is contained in:
Franck Nijhof 2024-05-24 22:01:44 +02:00 committed by GitHub
commit c347311851
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 705 additions and 67 deletions

View File

@ -414,6 +414,9 @@ async def async_from_config_dict(
start = monotonic()
hass.config_entries = config_entries.ConfigEntries(hass, config)
# Prime custom component cache early so we know if registry entries are tied
# to a custom integration
await loader.async_get_custom_components(hass)
await async_load_base_functionality(hass)
# Set up core.

View File

@ -19,5 +19,5 @@
"documentation": "https://www.home-assistant.io/integrations/aranet",
"integration_type": "device",
"iot_class": "local_push",
"requirements": ["aranet4==2.3.3"]
"requirements": ["aranet4==2.3.4"]
}

View File

@ -13,8 +13,8 @@
"crownstone_uart"
],
"requirements": [
"crownstone-cloud==1.4.9",
"crownstone-sse==2.0.4",
"crownstone-cloud==1.4.11",
"crownstone-sse==2.0.5",
"crownstone-uart==2.1.0",
"pyserial==3.5"
]

View File

@ -50,9 +50,9 @@ def get_hardware_variant(config_entry: ConfigEntry) -> HardwareVariant:
return HardwareVariant.from_usb_product_name(config_entry.data["product"])
def get_zha_device_path(config_entry: ConfigEntry) -> str:
def get_zha_device_path(config_entry: ConfigEntry) -> str | None:
"""Get the device path from a ZHA config entry."""
return cast(str, config_entry.data["device"]["path"])
return cast(str | None, config_entry.data.get("device", {}).get("path", None))
@singleton(OTBR_ADDON_MANAGER_DATA)
@ -94,13 +94,15 @@ async def guess_firmware_type(hass: HomeAssistant, device_path: str) -> Firmware
for zha_config_entry in hass.config_entries.async_entries(ZHA_DOMAIN):
zha_path = get_zha_device_path(zha_config_entry)
device_guesses[zha_path].append(
FirmwareGuess(
is_running=(zha_config_entry.state == ConfigEntryState.LOADED),
firmware_type=ApplicationType.EZSP,
source="zha",
if zha_path is not None:
device_guesses[zha_path].append(
FirmwareGuess(
is_running=(zha_config_entry.state == ConfigEntryState.LOADED),
firmware_type=ApplicationType.EZSP,
source="zha",
)
)
)
if is_hassio(hass):
otbr_addon_manager = get_otbr_addon_manager(hass)

View File

@ -54,7 +54,7 @@ def format_default(value: StateType) -> tuple[StateType, str | None]:
if value is not None:
# Clean up value and infer unit, e.g. -71dBm, 15 dB
if match := re.match(
r"([>=<]*)(?P<value>.+?)\s*(?P<unit>[a-zA-Z]+)\s*$", str(value)
r"((&[gl]t;|[><])=?)?(?P<value>.+?)\s*(?P<unit>[a-zA-Z]+)\s*$", str(value)
):
try:
value = float(match.group("value"))

View File

@ -52,7 +52,10 @@ DEFAULT_TRANSITION = 0.2
# sw version (attributeKey 0/40/10)
TRANSITION_BLOCKLIST = (
(4488, 514, "1.0", "1.0.0"),
(4488, 260, "1.0", "1.0.0"),
(5010, 769, "3.0", "1.0.0"),
(4999, 25057, "1.0", "27.0"),
(4448, 36866, "V1", "V1.0.0.5"),
)

View File

@ -542,6 +542,14 @@ class MQTT:
def _increase_socket_buffer_size(self, sock: SocketType) -> None:
"""Increase the socket buffer size."""
if not hasattr(sock, "setsockopt") and hasattr(sock, "_socket"):
# The WebsocketWrapper does not wrap setsockopt
# so we need to get the underlying socket
# Remove this once
# https://github.com/eclipse/paho.mqtt.python/pull/843
# is available.
sock = sock._socket # pylint: disable=protected-access
new_buffer_size = PREFERRED_BUFFER_SIZE
while True:
try:

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/philips_js",
"iot_class": "local_polling",
"loggers": ["haphilipsjs"],
"requirements": ["ha-philipsjs==3.1.1"]
"requirements": ["ha-philipsjs==3.2.1"]
}

View File

@ -91,13 +91,17 @@ class PlugwiseSelectEntity(PlugwiseEntity, SelectEntity):
super().__init__(coordinator, device_id)
self.entity_description = entity_description
self._attr_unique_id = f"{device_id}-{entity_description.key}"
self._attr_options = self.device[entity_description.options_key]
@property
def current_option(self) -> str:
"""Return the selected entity option to represent the entity state."""
return self.device[self.entity_description.key]
@property
def options(self) -> list[str]:
"""Return the available select-options."""
return self.device[self.entity_description.options_key]
async def async_select_option(self, option: str) -> None:
"""Change to the selected entity option."""
await self.entity_description.command(

View File

@ -7,5 +7,5 @@
"iot_class": "local_push",
"loggers": ["pyrisco"],
"quality_scale": "platinum",
"requirements": ["pyrisco==0.6.1"]
"requirements": ["pyrisco==0.6.2"]
}

View File

@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/rympro",
"iot_class": "cloud_polling",
"requirements": ["pyrympro==0.0.7"]
"requirements": ["pyrympro==0.0.8"]
}

View File

@ -256,6 +256,7 @@ class ShellyConfigFlow(ConfigFlow, domain=DOMAIN):
if (
current_entry := await self.async_set_unique_id(mac)
) and current_entry.data.get(CONF_HOST) == host:
LOGGER.debug("async_reconnect_soon: host: %s, mac: %s", host, mac)
await async_reconnect_soon(self.hass, current_entry)
if host == INTERNAL_WIFI_AP_IP:
# If the device is broadcasting the internal wifi ap ip

View File

@ -53,14 +53,16 @@ def get_thumbnail_url_full(
media_content_type: str,
media_content_id: str,
media_image_id: str | None = None,
item: MusicServiceItem | None = None,
) -> str | None:
"""Get thumbnail URL."""
if is_internal:
item = get_media(
media.library,
media_content_id,
media_content_type,
)
if not item:
item = get_media(
media.library,
media_content_id,
media_content_type,
)
return urllib.parse.unquote(getattr(item, "album_art_uri", ""))
return urllib.parse.unquote(
@ -255,7 +257,7 @@ def item_payload(item: DidlObject, get_thumbnail_url=None) -> BrowseMedia:
content_id = get_content_id(item)
thumbnail = None
if getattr(item, "album_art_uri", None):
thumbnail = get_thumbnail_url(media_class, content_id)
thumbnail = get_thumbnail_url(media_class, content_id, item=item)
return BrowseMedia(
title=item.title,

View File

@ -8,6 +8,7 @@ from typing import Any
from switchbot import (
SwitchbotAccountConnectionError,
SwitchBotAdvertisement,
SwitchbotApiError,
SwitchbotAuthenticationError,
SwitchbotLock,
SwitchbotModel,
@ -33,6 +34,7 @@ from homeassistant.const import (
)
from homeassistant.core import callback
from homeassistant.data_entry_flow import AbortFlow
from homeassistant.helpers.aiohttp_client import async_get_clientsession
from .const import (
CONF_ENCRYPTION_KEY,
@ -175,14 +177,19 @@ class SwitchbotConfigFlow(ConfigFlow, domain=DOMAIN):
description_placeholders = {}
if user_input is not None:
try:
key_details = await self.hass.async_add_executor_job(
SwitchbotLock.retrieve_encryption_key,
key_details = await SwitchbotLock.async_retrieve_encryption_key(
async_get_clientsession(self.hass),
self._discovered_adv.address,
user_input[CONF_USERNAME],
user_input[CONF_PASSWORD],
)
except SwitchbotAccountConnectionError as ex:
raise AbortFlow("cannot_connect") from ex
except (SwitchbotApiError, SwitchbotAccountConnectionError) as ex:
_LOGGER.debug(
"Failed to connect to SwitchBot API: %s", ex, exc_info=True
)
raise AbortFlow(
"api_error", description_placeholders={"error_detail": str(ex)}
) from ex
except SwitchbotAuthenticationError as ex:
_LOGGER.debug("Authentication failed: %s", ex, exc_info=True)
errors = {"base": "auth_failed"}

View File

@ -39,5 +39,5 @@
"documentation": "https://www.home-assistant.io/integrations/switchbot",
"iot_class": "local_push",
"loggers": ["switchbot"],
"requirements": ["PySwitchbot==0.45.0"]
"requirements": ["PySwitchbot==0.46.1"]
}

View File

@ -46,7 +46,7 @@
"already_configured_device": "[%key:common::config_flow::abort::already_configured_device%]",
"no_devices_found": "No supported SwitchBot devices found in range; If the device is in range, ensure the scanner has active scanning enabled, as SwitchBot devices cannot be discovered with passive scans. Active scans can be disabled once the device is configured. If you need clarification on whether the device is in-range, download the diagnostics for the integration that provides your Bluetooth adapter or proxy and check if the MAC address of the SwitchBot device is present.",
"unknown": "[%key:common::config_flow::error::unknown%]",
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
"api_error": "Error while communicating with SwitchBot API: {error_detail}",
"switchbot_unsupported_type": "Unsupported Switchbot Type."
}
},

View File

@ -27,7 +27,9 @@ from .models import SynologyDSMData
async def async_get_media_source(hass: HomeAssistant) -> MediaSource:
"""Set up Synology media source."""
entries = hass.config_entries.async_entries(DOMAIN)
entries = hass.config_entries.async_entries(
DOMAIN, include_disabled=False, include_ignore=False
)
hass.http.register_view(SynologyDsmMediaView(hass))
return SynologyPhotosMediaSource(hass, entries)

View File

@ -37,7 +37,7 @@
"not_connected": "Vehicle not connected",
"connected": "Vehicle connected",
"ready": "Ready to charge",
"negociating": "Negociating connection",
"negotiating": "Negotiating connection",
"error": "Error",
"charging_finished": "Charging finished",
"waiting_car": "Waiting for car",

View File

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

View File

@ -514,6 +514,15 @@ class ConfigEntry:
# Only store setup result as state if it was not forwarded.
if domain_is_integration := self.domain == integration.domain:
if self.state in (
ConfigEntryState.LOADED,
ConfigEntryState.SETUP_IN_PROGRESS,
):
raise OperationNotAllowed(
f"The config entry {self.title} ({self.domain}) with entry_id"
f" {self.entry_id} cannot be setup because is already loaded in the"
f" {self.state} state"
)
self._async_set_state(hass, ConfigEntryState.SETUP_IN_PROGRESS, None)
if self.supports_unload is None:
@ -709,6 +718,17 @@ class ConfigEntry:
) -> None:
"""Set up while holding the setup lock."""
async with self.setup_lock:
if self.state is ConfigEntryState.LOADED:
# If something loaded the config entry while
# we were waiting for the lock, we should not
# set it up again.
_LOGGER.debug(
"Not setting up %s (%s %s) again, already loaded",
self.title,
self.domain,
self.entry_id,
)
return
await self.async_setup(hass, integration=integration)
@callback

View File

@ -23,7 +23,7 @@ if TYPE_CHECKING:
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 5
PATCH_VERSION: Final = "4"
PATCH_VERSION: Final = "5"
__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

@ -1766,7 +1766,6 @@ class _TrackUTCTimeChange:
# time when the timer was scheduled
utc_now = time_tracker_utcnow()
localized_now = dt_util.as_local(utc_now) if self.local else utc_now
hass.async_run_hass_job(self.job, localized_now, background=True)
if TYPE_CHECKING:
assert self._pattern_time_change_listener_job is not None
self._cancel_callback = async_track_point_in_utc_time(
@ -1774,6 +1773,7 @@ class _TrackUTCTimeChange:
self._pattern_time_change_listener_job,
self._calculate_next(utc_now + timedelta(seconds=1)),
)
hass.async_run_hass_job(self.job, localized_now, background=True)
@callback
def async_cancel(self) -> None:

View File

@ -96,6 +96,11 @@ BLOCKED_CUSTOM_INTEGRATIONS: dict[str, BlockedIntegration] = {
"dreame_vacuum": BlockedIntegration(
AwesomeVersion("1.0.4"), "crashes Home Assistant"
),
# Added in 2024.5.5 because of
# https://github.com/sh00t2kill/dolphin-robot/issues/185
"mydolphin_plus": BlockedIntegration(
AwesomeVersion("1.0.13"), "crashes Home Assistant"
),
}
DATA_COMPONENTS = "components"
@ -1669,6 +1674,14 @@ def async_get_issue_tracker(
# If we know nothing about the entity, suggest opening an issue on HA core
return issue_tracker
if (
not integration
and (hass and integration_domain)
and (comps_or_future := hass.data.get(DATA_CUSTOM_COMPONENTS))
and not isinstance(comps_or_future, asyncio.Future)
):
integration = comps_or_future.get(integration_domain)
if not integration and (hass and integration_domain):
with suppress(IntegrationNotLoaded):
integration = async_get_loaded_integration(hass, integration_domain)

View File

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

View File

@ -93,7 +93,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.switchbot
PySwitchbot==0.45.0
PySwitchbot==0.46.1
# homeassistant.components.switchmate
PySwitchmate==0.5.1
@ -461,7 +461,7 @@ aprslib==0.7.2
aqualogic==2.6
# homeassistant.components.aranet
aranet4==2.3.3
aranet4==2.3.4
# homeassistant.components.arcam_fmj
arcam-fmj==1.4.0
@ -670,10 +670,10 @@ construct==2.10.68
croniter==2.0.2
# homeassistant.components.crownstone
crownstone-cloud==1.4.9
crownstone-cloud==1.4.11
# homeassistant.components.crownstone
crownstone-sse==2.0.4
crownstone-sse==2.0.5
# homeassistant.components.crownstone
crownstone-uart==2.1.0
@ -1029,7 +1029,7 @@ ha-ffmpeg==3.2.0
ha-iotawattpy==0.1.2
# homeassistant.components.philips_js
ha-philipsjs==3.1.1
ha-philipsjs==3.2.1
# homeassistant.components.habitica
habitipy==0.2.0
@ -2093,7 +2093,7 @@ pyrecswitch==1.0.2
pyrepetierng==0.1.0
# homeassistant.components.risco
pyrisco==0.6.1
pyrisco==0.6.2
# homeassistant.components.rituals_perfume_genie
pyrituals==0.0.6
@ -2102,7 +2102,7 @@ pyrituals==0.0.6
pyroute2==0.7.5
# homeassistant.components.rympro
pyrympro==0.0.7
pyrympro==0.0.8
# homeassistant.components.sabnzbd
pysabnzbd==1.1.1
@ -2866,7 +2866,7 @@ wiffi==1.1.2
wirelesstagpy==0.8.1
# homeassistant.components.wled
wled==0.17.1
wled==0.18.0
# homeassistant.components.wolflink
wolf-comm==0.0.7

View File

@ -81,7 +81,7 @@ PyQRCode==1.2.1
PyRMVtransport==0.3.3
# homeassistant.components.switchbot
PySwitchbot==0.45.0
PySwitchbot==0.46.1
# homeassistant.components.syncthru
PySyncThru==0.7.10
@ -422,7 +422,7 @@ apprise==1.7.4
aprslib==0.7.2
# homeassistant.components.aranet
aranet4==2.3.3
aranet4==2.3.4
# homeassistant.components.arcam_fmj
arcam-fmj==1.4.0
@ -554,10 +554,10 @@ construct==2.10.68
croniter==2.0.2
# homeassistant.components.crownstone
crownstone-cloud==1.4.9
crownstone-cloud==1.4.11
# homeassistant.components.crownstone
crownstone-sse==2.0.4
crownstone-sse==2.0.5
# homeassistant.components.crownstone
crownstone-uart==2.1.0
@ -843,7 +843,7 @@ ha-ffmpeg==3.2.0
ha-iotawattpy==0.1.2
# homeassistant.components.philips_js
ha-philipsjs==3.1.1
ha-philipsjs==3.2.1
# homeassistant.components.habitica
habitipy==0.2.0
@ -1635,7 +1635,7 @@ pyqwikswitch==0.93
pyrainbird==4.0.2
# homeassistant.components.risco
pyrisco==0.6.1
pyrisco==0.6.2
# homeassistant.components.rituals_perfume_genie
pyrituals==0.0.6
@ -1644,7 +1644,7 @@ pyrituals==0.0.6
pyroute2==0.7.5
# homeassistant.components.rympro
pyrympro==0.0.7
pyrympro==0.0.8
# homeassistant.components.sabnzbd
pysabnzbd==1.1.1
@ -2222,7 +2222,7 @@ whois==0.9.27
wiffi==1.1.2
# homeassistant.components.wled
wled==0.17.1
wled==0.18.0
# homeassistant.components.wolflink
wolf-comm==0.0.7

View File

@ -94,6 +94,18 @@ def test_get_zha_device_path() -> None:
)
def test_get_zha_device_path_ignored_discovery() -> None:
"""Test extracting the ZHA device path from an ignored ZHA discovery."""
config_entry = MockConfigEntry(
domain="zha",
unique_id="some_unique_id",
data={},
version=4,
)
assert get_zha_device_path(config_entry) is None
async def test_guess_firmware_type_unknown(hass: HomeAssistant) -> None:
"""Test guessing the firmware type."""

View File

@ -15,6 +15,8 @@ from homeassistant.const import (
("-71 dBm", (-71, SIGNAL_STRENGTH_DECIBELS_MILLIWATT)),
("15dB", (15, SIGNAL_STRENGTH_DECIBELS)),
(">=-51dBm", (-51, SIGNAL_STRENGTH_DECIBELS_MILLIWATT)),
("&lt;-20dB", (-20, SIGNAL_STRENGTH_DECIBELS)),
("&gt;=30dB", (30, SIGNAL_STRENGTH_DECIBELS)),
],
)
def test_format_default(value, expected) -> None:

View File

@ -4410,6 +4410,43 @@ async def test_server_sock_buffer_size(
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)
async def test_server_sock_buffer_size_with_websocket(
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)
class FakeWebsocket(paho_mqtt.WebsocketWrapper):
def _do_handshake(self, *args, **kwargs):
pass
wrapped_socket = FakeWebsocket(client, "127.0.01", 1, False, "/", None)
with patch.object(client, "setsockopt", side_effect=OSError("foo")):
mqtt_client_mock.on_socket_open(mqtt_client_mock, None, wrapped_socket)
mqtt_client_mock.on_socket_register_write(
mqtt_client_mock, None, wrapped_socket
)
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

@ -316,12 +316,35 @@ def sonos_favorites_fixture() -> SearchResult:
class MockMusicServiceItem:
"""Mocks a Soco MusicServiceItem."""
def __init__(self, title: str, item_id: str, parent_id: str, item_class: str):
def __init__(
self,
title: str,
item_id: str,
parent_id: str,
item_class: str,
album_art_uri: None | str = None,
):
"""Initialize the mock item."""
self.title = title
self.item_id = item_id
self.item_class = item_class
self.parent_id = parent_id
self.album_art_uri: None | str = album_art_uri
def list_from_json_fixture(file_name: str) -> list[MockMusicServiceItem]:
"""Create a list of music service items from a json fixture file."""
item_list = load_json_value_fixture(file_name, "sonos")
return [
MockMusicServiceItem(
item.get("title"),
item.get("item_id"),
item.get("parent_id"),
item.get("item_class"),
item.get("album_art_uri"),
)
for item in item_list
]
def mock_browse_by_idstring(
@ -398,6 +421,10 @@ def mock_browse_by_idstring(
"object.container.album.musicAlbum",
),
]
if search_type == "tracks":
return list_from_json_fixture("music_library_tracks.json")
if search_type == "albums" and idstring == "A:ALBUM":
return list_from_json_fixture("music_library_albums.json")
return []
@ -416,13 +443,23 @@ def mock_get_music_library_information(
]
@pytest.fixture(name="music_library_browse_categories")
def music_library_browse_categories() -> list[MockMusicServiceItem]:
"""Create fixture for top-level music library categories."""
return list_from_json_fixture("music_library_categories.json")
@pytest.fixture(name="music_library")
def music_library_fixture(sonos_favorites: SearchResult) -> Mock:
def music_library_fixture(
sonos_favorites: SearchResult,
music_library_browse_categories: list[MockMusicServiceItem],
) -> Mock:
"""Create music_library fixture."""
music_library = MagicMock()
music_library.get_sonos_favorites.return_value = sonos_favorites
music_library.browse_by_idstring = mock_browse_by_idstring
music_library.browse_by_idstring = Mock(side_effect=mock_browse_by_idstring)
music_library.get_music_library_information = mock_get_music_library_information
music_library.browse = Mock(return_value=music_library_browse_categories)
return music_library

View File

@ -0,0 +1,23 @@
[
{
"title": "A Hard Day's Night",
"item_id": "A:ALBUM/A%20Hard%20Day's%20Night",
"parent_id": "A:ALBUM",
"item_class": "object.container.album.musicAlbum",
"album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.100%2fmusic%2fThe%2520Beatles%2fA%2520Hard%2520Day's%2520Night%2f01%2520A%2520Hard%2520Day's%2520Night%25201.m4a&v=53"
},
{
"title": "Abbey Road",
"item_id": "A:ALBUM/Abbey%20Road",
"parent_id": "A:ALBUM",
"item_class": "object.container.album.musicAlbum",
"album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.100%2fmusic%2fThe%2520Beatles%2fAbbeyA%2520Road%2f01%2520Come%2520Together.m4a&v=53"
},
{
"title": "Between Good And Evil",
"item_id": "A:ALBUM/Between%20Good%20And%20Evil",
"parent_id": "A:ALBUM",
"item_class": "object.container.album.musicAlbum",
"album_art_uri": "http://192.168.42.2:1400/getaa?u=x-file-cifs%3a%2f%2f192.168.42.100%2fmusic%2fSantana%2fA%2520Between%2520Good%2520And%2520Evil%2f02%2520A%2520Persuasion.m4a&v=53"
}
]

View File

@ -0,0 +1,44 @@
[
{
"title": "Contributing Artists",
"item_id": "A:ARTIST",
"parent_id": "A:",
"item_class": "object.container"
},
{
"title": "Artists",
"item_id": "A:ALBUMARTIST",
"parent_id": "A:",
"item_class": "object.container"
},
{
"title": "Albums",
"item_id": "A:ALBUM",
"parent_id": "A:",
"item_class": "object.container"
},
{
"title": "Genres",
"item_id": "A:GENRE",
"parent_id": "A:",
"item_class": "object.container"
},
{
"title": "Composers",
"item_id": "A:COMPOSER",
"parent_id": "A:",
"item_class": "object.container"
},
{
"title": "Tracks",
"item_id": "A:TRACKS",
"parent_id": "A:",
"item_class": "object.container.playlistContainer"
},
{
"title": "Playlists",
"item_id": "A:PLAYLISTS",
"parent_id": "A:",
"item_class": "object.container"
}
]

View File

@ -0,0 +1,14 @@
[
{
"title": "A Hard Day's Night",
"item_id": "S://192.168.42.100/music/iTunes/The%20Beatles/A%20Hard%20Day%2fs%20Night/A%20Hard%20Day%2fs%20Night.mp3",
"parent_id": "A:ALBUMARTIST/Beatles/A%20Hard%20Day's%20Night",
"item_class": "object.container.album.musicTrack"
},
{
"title": "I Should Have Known Better",
"item_id": "S://192.168.42.100/music/iTunes/The%20Beatles/A%20Hard%20Day%2fs%I%20Should%20Have%20Known%20Better.mp3",
"parent_id": "A:ALBUMARTIST/Beatles/A%20Hard%20Day's%20Night",
"item_class": "object.container.album.musicTrack"
}
]

View File

@ -0,0 +1,133 @@
# serializer version: 1
# name: test_browse_media_library
list([
dict({
'can_expand': True,
'can_play': False,
'children_media_class': None,
'media_class': 'contributing_artist',
'media_content_id': 'A:ARTIST',
'media_content_type': 'contributing_artist',
'thumbnail': None,
'title': 'Contributing Artists',
}),
dict({
'can_expand': True,
'can_play': False,
'children_media_class': None,
'media_class': 'artist',
'media_content_id': 'A:ALBUMARTIST',
'media_content_type': 'artist',
'thumbnail': None,
'title': 'Artists',
}),
dict({
'can_expand': True,
'can_play': False,
'children_media_class': None,
'media_class': 'album',
'media_content_id': 'A:ALBUM',
'media_content_type': 'album',
'thumbnail': None,
'title': 'Albums',
}),
dict({
'can_expand': True,
'can_play': False,
'children_media_class': None,
'media_class': 'genre',
'media_content_id': 'A:GENRE',
'media_content_type': 'genre',
'thumbnail': None,
'title': 'Genres',
}),
dict({
'can_expand': True,
'can_play': False,
'children_media_class': None,
'media_class': 'composer',
'media_content_id': 'A:COMPOSER',
'media_content_type': 'composer',
'thumbnail': None,
'title': 'Composers',
}),
dict({
'can_expand': True,
'can_play': True,
'children_media_class': None,
'media_class': 'track',
'media_content_id': 'A:TRACKS',
'media_content_type': 'track',
'thumbnail': None,
'title': 'Tracks',
}),
dict({
'can_expand': True,
'can_play': False,
'children_media_class': None,
'media_class': 'playlist',
'media_content_id': 'A:PLAYLISTS',
'media_content_type': 'playlist',
'thumbnail': None,
'title': 'Playlists',
}),
])
# ---
# name: test_browse_media_library_albums
list([
dict({
'can_expand': True,
'can_play': True,
'children_media_class': None,
'media_class': 'album',
'media_content_id': "A:ALBUM/A%20Hard%20Day's%20Night",
'media_content_type': 'album',
'thumbnail': "http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.100/music/The%20Beatles/A%20Hard%20Day's%20Night/01%20A%20Hard%20Day's%20Night%201.m4a&v=53",
'title': "A Hard Day's Night",
}),
dict({
'can_expand': True,
'can_play': True,
'children_media_class': None,
'media_class': 'album',
'media_content_id': 'A:ALBUM/Abbey%20Road',
'media_content_type': 'album',
'thumbnail': 'http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.100/music/The%20Beatles/AbbeyA%20Road/01%20Come%20Together.m4a&v=53',
'title': 'Abbey Road',
}),
dict({
'can_expand': True,
'can_play': True,
'children_media_class': None,
'media_class': 'album',
'media_content_id': 'A:ALBUM/Between%20Good%20And%20Evil',
'media_content_type': 'album',
'thumbnail': 'http://192.168.42.2:1400/getaa?u=x-file-cifs://192.168.42.100/music/Santana/A%20Between%20Good%20And%20Evil/02%20A%20Persuasion.m4a&v=53',
'title': 'Between Good And Evil',
}),
])
# ---
# name: test_browse_media_root
list([
dict({
'can_expand': True,
'can_play': False,
'children_media_class': None,
'media_class': 'directory',
'media_content_id': '',
'media_content_type': 'favorites',
'thumbnail': 'https://brands.home-assistant.io/_/sonos/logo.png',
'title': 'Favorites',
}),
dict({
'can_expand': True,
'can_play': False,
'children_media_class': None,
'media_class': 'directory',
'media_content_id': '',
'media_content_type': 'library',
'thumbnail': 'https://brands.home-assistant.io/_/sonos/logo.png',
'title': 'Music Library',
}),
])
# ---

View File

@ -2,6 +2,8 @@
from functools import partial
from syrupy import SnapshotAssertion
from homeassistant.components.media_player.browse_media import BrowseMedia
from homeassistant.components.media_player.const import MediaClass, MediaType
from homeassistant.components.sonos.media_browser import (
@ -12,6 +14,8 @@ from homeassistant.core import HomeAssistant
from .conftest import SoCoMockFactory
from tests.typing import WebSocketGenerator
class MockMusicServiceItem:
"""Mocks a Soco MusicServiceItem."""
@ -95,3 +99,81 @@ async def test_build_item_response(
browse_item.children[1].media_content_id
== "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3"
)
async def test_browse_media_root(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
async_autosetup_sonos,
soco,
discover,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test the async_browse_media method."""
client = await hass_ws_client()
await client.send_json(
{
"id": 1,
"type": "media_player/browse_media",
"entity_id": "media_player.zone_a",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"]["children"] == snapshot
async def test_browse_media_library(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
async_autosetup_sonos,
soco,
discover,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test the async_browse_media method."""
client = await hass_ws_client()
await client.send_json(
{
"id": 1,
"type": "media_player/browse_media",
"entity_id": "media_player.zone_a",
"media_content_id": "",
"media_content_type": "library",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"]["children"] == snapshot
async def test_browse_media_library_albums(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
async_autosetup_sonos,
soco,
discover,
hass_ws_client: WebSocketGenerator,
snapshot: SnapshotAssertion,
) -> None:
"""Test the async_browse_media method."""
soco_mock = soco_factory.mock_list.get("192.168.42.2")
client = await hass_ws_client()
await client.send_json(
{
"id": 1,
"type": "media_player/browse_media",
"entity_id": "media_player.zone_a",
"media_content_id": "A:ALBUM",
"media_content_type": "album",
}
)
response = await client.receive_json()
assert response["success"]
assert response["result"]["children"] == snapshot
assert soco_mock.music_library.browse_by_idstring.call_count == 1

View File

@ -487,7 +487,7 @@ async def test_user_setup_wolock_auth(hass: HomeAssistant) -> None:
assert result["errors"] == {}
with patch(
"homeassistant.components.switchbot.config_flow.SwitchbotLock.retrieve_encryption_key",
"homeassistant.components.switchbot.config_flow.SwitchbotLock.async_retrieve_encryption_key",
side_effect=SwitchbotAuthenticationError("error from api"),
):
result = await hass.config_entries.flow.async_configure(
@ -510,7 +510,7 @@ async def test_user_setup_wolock_auth(hass: HomeAssistant) -> None:
return_value=True,
),
patch(
"homeassistant.components.switchbot.config_flow.SwitchbotLock.retrieve_encryption_key",
"homeassistant.components.switchbot.config_flow.SwitchbotLock.async_retrieve_encryption_key",
return_value={
CONF_KEY_ID: "ff",
CONF_ENCRYPTION_KEY: "ffffffffffffffffffffffffffffffff",
@ -560,8 +560,8 @@ async def test_user_setup_wolock_auth_switchbot_api_down(hass: HomeAssistant) ->
assert result["errors"] == {}
with patch(
"homeassistant.components.switchbot.config_flow.SwitchbotLock.retrieve_encryption_key",
side_effect=SwitchbotAccountConnectionError,
"homeassistant.components.switchbot.config_flow.SwitchbotLock.async_retrieve_encryption_key",
side_effect=SwitchbotAccountConnectionError("Switchbot API down"),
):
result = await hass.config_entries.flow.async_configure(
result["flow_id"],
@ -572,7 +572,8 @@ async def test_user_setup_wolock_auth_switchbot_api_down(hass: HomeAssistant) ->
)
await hass.async_block_till_done()
assert result["type"] is FlowResultType.ABORT
assert result["reason"] == "cannot_connect"
assert result["reason"] == "api_error"
assert result["description_placeholders"] == {"error_detail": "Switchbot API down"}
async def test_user_setup_wolock_or_bot(hass: HomeAssistant) -> None:

View File

@ -196,7 +196,7 @@ async def test_flow_ssdp_discovery_changed_udn_match_mac(hass: HomeAssistant) ->
CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS,
},
source=config_entries.SOURCE_SSDP,
state=config_entries.ConfigEntryState.LOADED,
state=config_entries.ConfigEntryState.NOT_LOADED,
)
entry.add_to_hass(hass)
@ -228,7 +228,7 @@ async def test_flow_ssdp_discovery_changed_udn_match_host(hass: HomeAssistant) -
CONFIG_ENTRY_HOST: TEST_HOST,
},
source=config_entries.SOURCE_SSDP,
state=config_entries.ConfigEntryState.LOADED,
state=config_entries.ConfigEntryState.NOT_LOADED,
)
entry.add_to_hass(hass)
@ -266,7 +266,7 @@ async def test_flow_ssdp_discovery_changed_udn_but_st_differs(
CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS,
},
source=config_entries.SOURCE_SSDP,
state=config_entries.ConfigEntryState.LOADED,
state=config_entries.ConfigEntryState.NOT_LOADED,
)
entry.add_to_hass(hass)
@ -320,7 +320,7 @@ async def test_flow_ssdp_discovery_changed_location(hass: HomeAssistant) -> None
CONFIG_ENTRY_MAC_ADDRESS: TEST_MAC_ADDRESS,
},
source=config_entries.SOURCE_SSDP,
state=config_entries.ConfigEntryState.LOADED,
state=config_entries.ConfigEntryState.NOT_LOADED,
)
entry.add_to_hass(hass)

View File

@ -4589,6 +4589,40 @@ async def test_async_track_point_in_time_cancel(hass: HomeAssistant) -> None:
assert "US/Hawaii" in str(times[0].tzinfo)
async def test_async_track_point_in_time_cancel_in_job(
hass: HomeAssistant, freezer: FrozenDateTimeFactory
) -> None:
"""Test cancel of async track point in time during job execution."""
now = dt_util.utcnow()
times = []
time_that_will_not_match_right_away = datetime(
now.year + 1, 5, 24, 11, 59, 55, tzinfo=dt_util.UTC
)
freezer.move_to(time_that_will_not_match_right_away)
@callback
def action(x: datetime):
nonlocal times
times.append(x)
unsub()
unsub = async_track_utc_time_change(hass, action, minute=0, second="*")
async_fire_time_changed(
hass, datetime(now.year + 1, 5, 24, 12, 0, 0, 999999, tzinfo=dt_util.UTC)
)
await hass.async_block_till_done()
assert len(times) == 1
async_fire_time_changed(
hass, datetime(now.year + 1, 5, 24, 13, 0, 0, 999999, tzinfo=dt_util.UTC)
)
await hass.async_block_till_done()
assert len(times) == 1
async def test_async_track_entity_registry_updated_event(hass: HomeAssistant) -> None:
"""Test tracking entity registry updates for an entity_id."""

View File

@ -90,6 +90,89 @@ async def manager(hass: HomeAssistant) -> config_entries.ConfigEntries:
return manager
async def test_setup_race_only_setup_once(hass: HomeAssistant) -> None:
"""Test ensure that config entries are only setup once."""
attempts = 0
slow_config_entry_setup_future = hass.loop.create_future()
fast_config_entry_setup_future = hass.loop.create_future()
slow_setup_future = hass.loop.create_future()
async def async_setup(hass, config):
"""Mock setup."""
await slow_setup_future
return True
async def async_setup_entry(hass, entry):
"""Mock setup entry."""
slow = entry.data["slow"]
if slow:
await slow_config_entry_setup_future
return True
nonlocal attempts
attempts += 1
if attempts == 1:
raise ConfigEntryNotReady
await fast_config_entry_setup_future
return True
async def async_unload_entry(hass, entry):
"""Mock unload entry."""
return True
mock_integration(
hass,
MockModule(
"comp",
async_setup=async_setup,
async_setup_entry=async_setup_entry,
async_unload_entry=async_unload_entry,
),
)
mock_platform(hass, "comp.config_flow", None)
entry = MockConfigEntry(domain="comp", data={"slow": False})
entry.add_to_hass(hass)
entry2 = MockConfigEntry(domain="comp", data={"slow": True})
entry2.add_to_hass(hass)
await entry2.setup_lock.acquire()
async def _async_reload_entry(entry: MockConfigEntry):
async with entry.setup_lock:
await entry.async_unload(hass)
await entry.async_setup(hass)
hass.async_create_task(_async_reload_entry(entry2))
setup_task = hass.async_create_task(async_setup_component(hass, "comp", {}))
entry2.setup_lock.release()
assert entry.state is config_entries.ConfigEntryState.NOT_LOADED
assert entry2.state is config_entries.ConfigEntryState.NOT_LOADED
assert "comp" not in hass.config.components
slow_setup_future.set_result(None)
await asyncio.sleep(0)
assert "comp" in hass.config.components
assert entry.state is config_entries.ConfigEntryState.SETUP_RETRY
assert entry2.state is config_entries.ConfigEntryState.SETUP_IN_PROGRESS
fast_config_entry_setup_future.set_result(None)
# Make sure setup retry is started
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=5))
slow_config_entry_setup_future.set_result(None)
await hass.async_block_till_done()
assert entry.state is config_entries.ConfigEntryState.LOADED
await hass.async_block_till_done()
assert attempts == 2
await hass.async_block_till_done()
assert setup_task.done()
assert entry2.state is config_entries.ConfigEntryState.LOADED
async def test_call_setup_entry(hass: HomeAssistant) -> None:
"""Test we call <component>.setup_entry."""
entry = MockConfigEntry(domain="comp")
@ -386,7 +469,7 @@ async def test_remove_entry(
]
# Setup entry
await entry.async_setup(hass)
await manager.async_setup(entry.entry_id)
await hass.async_block_till_done()
# Check entity state got added
@ -1613,7 +1696,9 @@ async def test_entry_reload_succeed(
hass: HomeAssistant, manager: config_entries.ConfigEntries
) -> None:
"""Test that we can reload an entry."""
entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED)
entry = MockConfigEntry(
domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED
)
entry.add_to_hass(hass)
async_setup = AsyncMock(return_value=True)
@ -1637,6 +1722,42 @@ async def test_entry_reload_succeed(
assert entry.state is config_entries.ConfigEntryState.LOADED
@pytest.mark.parametrize(
"state",
[
config_entries.ConfigEntryState.LOADED,
config_entries.ConfigEntryState.SETUP_IN_PROGRESS,
],
)
async def test_entry_cannot_be_loaded_twice(
hass: HomeAssistant, state: config_entries.ConfigEntryState
) -> None:
"""Test that a config entry cannot be loaded twice."""
entry = MockConfigEntry(domain="comp", state=state)
entry.add_to_hass(hass)
async_setup = AsyncMock(return_value=True)
async_setup_entry = AsyncMock(return_value=True)
async_unload_entry = AsyncMock(return_value=True)
mock_integration(
hass,
MockModule(
"comp",
async_setup=async_setup,
async_setup_entry=async_setup_entry,
async_unload_entry=async_unload_entry,
),
)
mock_platform(hass, "comp.config_flow", None)
with pytest.raises(config_entries.OperationNotAllowed, match=str(state)):
await entry.async_setup(hass)
assert len(async_setup.mock_calls) == 0
assert len(async_setup_entry.mock_calls) == 0
assert entry.state is state
@pytest.mark.parametrize(
"state",
[
@ -4005,7 +4126,9 @@ async def test_entry_reload_concurrency_not_setup_setup(
hass: HomeAssistant, manager: config_entries.ConfigEntries
) -> None:
"""Test multiple reload calls do not cause a reload race."""
entry = MockConfigEntry(domain="comp", state=config_entries.ConfigEntryState.LOADED)
entry = MockConfigEntry(
domain="comp", state=config_entries.ConfigEntryState.NOT_LOADED
)
entry.add_to_hass(hass)
async_setup = AsyncMock(return_value=True)

View File

@ -2,6 +2,7 @@
import asyncio
import os
import pathlib
import sys
import threading
from typing import Any
@ -1108,14 +1109,18 @@ CUSTOM_ISSUE_TRACKER = "https://blablabla.com"
# Integration domain is not currently deduced from module
(None, "homeassistant.components.hue.sensor", CORE_ISSUE_TRACKER),
("hue", "homeassistant.components.mqtt.sensor", CORE_ISSUE_TRACKER_HUE),
# Custom integration with known issue tracker
# Loaded custom integration with known issue tracker
("bla_custom", "custom_components.bla_custom.sensor", CUSTOM_ISSUE_TRACKER),
("bla_custom", None, CUSTOM_ISSUE_TRACKER),
# Custom integration without known issue tracker
# Loaded custom integration without known issue tracker
(None, "custom_components.bla.sensor", None),
("bla_custom_no_tracker", "custom_components.bla_custom.sensor", None),
("bla_custom_no_tracker", None, None),
("hue", "custom_components.bla.sensor", None),
# Unloaded custom integration with known issue tracker
("bla_custom_not_loaded", None, CUSTOM_ISSUE_TRACKER),
# Unloaded custom integration without known issue tracker
("bla_custom_not_loaded_no_tracker", None, None),
# Integration domain has priority over module
("bla_custom_no_tracker", "homeassistant.components.bla_custom.sensor", None),
],
@ -1133,6 +1138,32 @@ async def test_async_get_issue_tracker(
built_in=False,
)
mock_integration(hass, MockModule("bla_custom_no_tracker"), built_in=False)
cust_unloaded_module = MockModule(
"bla_custom_not_loaded",
partial_manifest={"issue_tracker": CUSTOM_ISSUE_TRACKER},
)
cust_unloaded = loader.Integration(
hass,
f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{cust_unloaded_module.DOMAIN}",
pathlib.Path(""),
cust_unloaded_module.mock_manifest(),
set(),
)
cust_unloaded_no_tracker_module = MockModule("bla_custom_not_loaded_no_tracker")
cust_unloaded_no_tracker = loader.Integration(
hass,
f"{loader.PACKAGE_CUSTOM_COMPONENTS}.{cust_unloaded_no_tracker_module.DOMAIN}",
pathlib.Path(""),
cust_unloaded_no_tracker_module.mock_manifest(),
set(),
)
hass.data["custom_components"] = {
"bla_custom_not_loaded": cust_unloaded,
"bla_custom_not_loaded_no_tracker": cust_unloaded_no_tracker,
}
assert (
loader.async_get_issue_tracker(hass, integration_domain=domain, module=module)
== issue_tracker