This commit is contained in:
Paulus Schoutsen 2024-05-10 15:08:20 -04:00 committed by GitHub
commit 9b6500582a
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
30 changed files with 441 additions and 97 deletions

View File

@ -60,8 +60,8 @@
"description": "Type of push notification to send to list members."
},
"item": {
"name": "Item (Required if message type `Breaking news` selected)",
"description": "Item name to include in a breaking news message e.g. `Breaking news - Please get cilantro!`"
"name": "Article (Required if message type `Urgent Message` selected)",
"description": "Article name to include in an urgent message e.g. `Urgent Message - Please buy Cilantro urgently`"
}
}
}
@ -69,10 +69,10 @@
"selector": {
"notification_type_selector": {
"options": {
"going_shopping": "I'm going shopping! - Last chance for adjustments",
"changed_list": "List changed - Check it out",
"shopping_done": "Shopping done - you can relax",
"urgent_message": "Breaking news - Please get `item`!"
"going_shopping": "I'm going shopping! - Last chance to make changes",
"changed_list": "List updated - Take a look at the articles",
"shopping_done": "Shopping done - The fridge is well stocked",
"urgent_message": "Urgent Message - Please buy `Article name` urgently"
}
}
}

View File

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

View File

@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"requirements": ["pyenphase==1.20.1"],
"requirements": ["pyenphase==1.20.3"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/goodwe",
"iot_class": "local_polling",
"loggers": ["goodwe"],
"requirements": ["goodwe==0.3.4"]
"requirements": ["goodwe==0.3.5"]
}

View File

@ -63,7 +63,7 @@ NUMBERS = (
native_unit_of_measurement=PERCENTAGE,
native_step=1,
native_min_value=0,
native_max_value=100,
native_max_value=200,
getter=lambda inv: inv.get_grid_export_limit(),
setter=lambda inv, val: inv.set_grid_export_limit(val),
filter=lambda inv: _get_setting_unit(inv, "grid_export_limit") == "%",

View File

@ -95,7 +95,10 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC):
_LOGGER.error(err)
raise AbortFlow(
"addon_set_config_failed",
description_placeholders=self._get_translation_placeholders(),
description_placeholders={
**self._get_translation_placeholders(),
"addon_name": addon_manager.addon_name,
},
) from err
async def _async_get_addon_info(self, addon_manager: AddonManager) -> AddonInfo:

View File

@ -212,13 +212,15 @@ class HomeKitWindowCover(HomeKitEntity, CoverEntity):
)
@property
def current_cover_tilt_position(self) -> int:
def current_cover_tilt_position(self) -> int | None:
"""Return current position of cover tilt."""
tilt_position = self.service.value(CharacteristicsTypes.VERTICAL_TILT_CURRENT)
if not tilt_position:
tilt_position = self.service.value(
CharacteristicsTypes.HORIZONTAL_TILT_CURRENT
)
if tilt_position is None:
return None
# Recalculate to convert from arcdegree scale to percentage scale.
if self.is_vertical_tilt:
scale = 0.9

View File

@ -317,7 +317,7 @@ class EnsureJobAfterCooldown:
self._loop = asyncio.get_running_loop()
self._timeout = timeout
self._callback = callback_job
self._task: asyncio.Future | None = None
self._task: asyncio.Task | None = None
self._timer: asyncio.TimerHandle | None = None
def set_timeout(self, timeout: float) -> None:
@ -332,28 +332,23 @@ class EnsureJobAfterCooldown:
_LOGGER.error("%s", ha_error)
@callback
def _async_task_done(self, task: asyncio.Future) -> None:
def _async_task_done(self, task: asyncio.Task) -> None:
"""Handle task done."""
self._task = None
@callback
def _async_execute(self) -> None:
def async_execute(self) -> asyncio.Task:
"""Execute the job."""
if self._task:
# Task already running,
# so we schedule another run
self.async_schedule()
return
return self._task
self._async_cancel_timer()
self._task = create_eager_task(self._async_job())
self._task.add_done_callback(self._async_task_done)
async def async_fire(self) -> None:
"""Execute the job immediately."""
if self._task:
await self._task
self._async_execute()
return self._task
@callback
def _async_cancel_timer(self) -> None:
@ -368,7 +363,7 @@ class EnsureJobAfterCooldown:
# We want to reschedule the timer in the future
# every time this is called.
self._async_cancel_timer()
self._timer = self._loop.call_later(self._timeout, self._async_execute)
self._timer = self._loop.call_later(self._timeout, self.async_execute)
async def async_cleanup(self) -> None:
"""Cleanup any pending task."""
@ -497,6 +492,9 @@ class MQTT:
mqttc.on_subscribe = self._async_mqtt_on_callback
mqttc.on_unsubscribe = self._async_mqtt_on_callback
# suppress exceptions at callback
mqttc.suppress_exceptions = True
if will := self.conf.get(CONF_WILL_MESSAGE, DEFAULT_WILL):
will_message = PublishMessage(**will)
mqttc.will_set(
@ -883,7 +881,7 @@ class MQTT:
await self._discovery_cooldown() # Wait for MQTT discovery to cool down
# Update subscribe cooldown period to a shorter time
# and make sure we flush the debouncer
await self._subscribe_debouncer.async_fire()
await self._subscribe_debouncer.async_execute()
self._subscribe_debouncer.set_timeout(SUBSCRIBE_COOLDOWN)
await self.async_publish(
topic=birth_message.topic,
@ -993,10 +991,21 @@ class MQTT:
def _async_mqtt_on_message(
self, _mqttc: mqtt.Client, _userdata: None, msg: mqtt.MQTTMessage
) -> None:
topic = msg.topic
# msg.topic is a property that decodes the topic to a string
# every time it is accessed. Save the result to avoid
# decoding the same topic multiple times.
try:
# msg.topic is a property that decodes the topic to a string
# every time it is accessed. Save the result to avoid
# decoding the same topic multiple times.
topic = msg.topic
except UnicodeDecodeError:
bare_topic: bytes = getattr(msg, "_topic")
_LOGGER.warning(
"Skipping received%s message on invalid topic %s (qos=%s): %s",
" retained" if msg.retain else "",
bare_topic,
msg.qos,
msg.payload[0:8192],
)
return
_LOGGER.debug(
"Received%s message on %s (qos=%s): %s",
" retained" if msg.retain else "",

View File

@ -1015,8 +1015,7 @@ class MqttDiscoveryUpdate(Entity):
self.hass.async_create_task(
_async_process_discovery_update_and_remove(
payload, self._discovery_data
),
eager_start=False,
)
)
elif self._discovery_update:
if old_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD]:
@ -1025,8 +1024,7 @@ class MqttDiscoveryUpdate(Entity):
self.hass.async_create_task(
_async_process_discovery_update(
payload, self._discovery_update, self._discovery_data
),
eager_start=False,
)
)
else:
# Non-empty, unchanged payload: Ignore to avoid changing states
@ -1059,6 +1057,15 @@ class MqttDiscoveryUpdate(Entity):
# rediscovered after a restart
await async_remove_discovery_payload(self.hass, self._discovery_data)
@final
async def add_to_platform_finish(self) -> None:
"""Finish adding entity to platform."""
await super().add_to_platform_finish()
# Only send the discovery done after the entity is fully added
# and the state is written to the state machine.
if self._discovery_data is not None:
send_discovery_done(self.hass, self._discovery_data)
@callback
def add_to_platform_abort(self) -> None:
"""Abort adding an entity to a platform."""
@ -1218,8 +1225,6 @@ class MqttEntity(
self._prepare_subscribe_topics()
await self._subscribe_topics()
await self.mqtt_async_added_to_hass()
if self._discovery_data is not None:
send_discovery_done(self.hass, self._discovery_data)
async def mqtt_async_added_to_hass(self) -> None:
"""Call before the discovery message is acknowledged.

View File

@ -2,8 +2,10 @@
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
import datetime
from functools import partial
import logging
from pynws import SimpleNWS, call_with_retry
@ -58,36 +60,49 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
nws_data = SimpleNWS(latitude, longitude, api_key, client_session)
await nws_data.set_station(station)
async def update_observation() -> None:
"""Retrieve recent observations."""
await call_with_retry(
nws_data.update_observation,
RETRY_INTERVAL,
RETRY_STOP,
start_time=utcnow() - UPDATE_TIME_PERIOD,
)
def async_setup_update_observation(
retry_interval: datetime.timedelta | float,
retry_stop: datetime.timedelta | float,
) -> Callable[[], Awaitable[None]]:
async def update_observation() -> None:
"""Retrieve recent observations."""
await call_with_retry(
nws_data.update_observation,
retry_interval,
retry_stop,
start_time=utcnow() - UPDATE_TIME_PERIOD,
)
async def update_forecast() -> None:
"""Retrieve twice-daily forecsat."""
await call_with_retry(
return update_observation
def async_setup_update_forecast(
retry_interval: datetime.timedelta | float,
retry_stop: datetime.timedelta | float,
) -> Callable[[], Awaitable[None]]:
return partial(
call_with_retry,
nws_data.update_forecast,
RETRY_INTERVAL,
RETRY_STOP,
retry_interval,
retry_stop,
)
async def update_forecast_hourly() -> None:
"""Retrieve hourly forecast."""
await call_with_retry(
def async_setup_update_forecast_hourly(
retry_interval: datetime.timedelta | float,
retry_stop: datetime.timedelta | float,
) -> Callable[[], Awaitable[None]]:
return partial(
call_with_retry,
nws_data.update_forecast_hourly,
RETRY_INTERVAL,
RETRY_STOP,
retry_interval,
retry_stop,
)
# Don't use retries in setup
coordinator_observation = TimestampDataUpdateCoordinator(
hass,
_LOGGER,
name=f"NWS observation station {station}",
update_method=update_observation,
update_method=async_setup_update_observation(0, 0),
update_interval=DEFAULT_SCAN_INTERVAL,
request_refresh_debouncer=debounce.Debouncer(
hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True
@ -98,7 +113,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass,
_LOGGER,
name=f"NWS forecast station {station}",
update_method=update_forecast,
update_method=async_setup_update_forecast(0, 0),
update_interval=DEFAULT_SCAN_INTERVAL,
request_refresh_debouncer=debounce.Debouncer(
hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True
@ -109,7 +124,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
hass,
_LOGGER,
name=f"NWS forecast hourly station {station}",
update_method=update_forecast_hourly,
update_method=async_setup_update_forecast_hourly(0, 0),
update_interval=DEFAULT_SCAN_INTERVAL,
request_refresh_debouncer=debounce.Debouncer(
hass, _LOGGER, cooldown=DEBOUNCE_TIME, immediate=True
@ -128,6 +143,17 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
await coordinator_forecast.async_refresh()
await coordinator_forecast_hourly.async_refresh()
# Use retries
coordinator_observation.update_method = async_setup_update_observation(
RETRY_INTERVAL, RETRY_STOP
)
coordinator_forecast.update_method = async_setup_update_forecast(
RETRY_INTERVAL, RETRY_STOP
)
coordinator_forecast_hourly.update_method = async_setup_update_forecast_hourly(
RETRY_INTERVAL, RETRY_STOP
)
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
return True

View File

@ -11,7 +11,7 @@
"iot_class": "local_polling",
"loggers": ["rokuecp"],
"quality_scale": "silver",
"requirements": ["rokuecp==0.19.2"],
"requirements": ["rokuecp==0.19.3"],
"ssdp": [
{
"st": "roku:ecp",

View File

@ -39,7 +39,7 @@ from homeassistant.components.plex.services import process_plex_payload
from homeassistant.config_entries import ConfigEntry
from homeassistant.const import ATTR_TIME
from homeassistant.core import HomeAssistant, ServiceCall, callback
from homeassistant.exceptions import HomeAssistantError
from homeassistant.exceptions import HomeAssistantError, ServiceValidationError
from homeassistant.helpers import config_validation as cv, entity_platform, service
from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback
@ -432,7 +432,13 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
fav = [fav for fav in self.speaker.favorites if fav.title == name]
if len(fav) != 1:
return
raise ServiceValidationError(
translation_domain=SONOS_DOMAIN,
translation_key="invalid_favorite",
translation_placeholders={
"name": name,
},
)
src = fav.pop()
self._play_favorite(src)
@ -445,7 +451,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
MUSIC_SRC_RADIO,
MUSIC_SRC_LINE_IN,
]:
soco.play_uri(uri, title=favorite.title)
soco.play_uri(uri, title=favorite.title, timeout=LONG_SERVICE_TIMEOUT)
else:
soco.clear_queue()
soco.add_to_queue(favorite.reference, timeout=LONG_SERVICE_TIMEOUT)

View File

@ -173,5 +173,10 @@
}
}
}
},
"exceptions": {
"invalid_favorite": {
"message": "Could not find a Sonos favorite: {name}"
}
}
}

View File

@ -5,5 +5,5 @@
"config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/v2c",
"iot_class": "local_polling",
"requirements": ["pytrydan==0.4.0"]
"requirements": ["pytrydan==0.6.0"]
}

View File

@ -83,7 +83,7 @@
"button_fan": "Button Fan \"{subtype}\"",
"button_swing": "Button Swing \"{subtype}\"",
"button_decrease_speed": "Button Decrease Speed \"{subtype}\"",
"button_increase_speed": "Button Inrease Speed \"{subtype}\"",
"button_increase_speed": "Button Increase Speed \"{subtype}\"",
"button_stop": "Button Stop \"{subtype}\"",
"button_light": "Button Light \"{subtype}\"",
"button_wind_speed": "Button Wind Speed \"{subtype}\"",

View File

@ -6,5 +6,5 @@
"dependencies": ["auth", "application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/yolink",
"iot_class": "cloud_push",
"requirements": ["yolink-api==0.4.3"]
"requirements": ["yolink-api==0.4.4"]
}

View File

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

@ -182,7 +182,10 @@ class EntityComponent(Generic[_EntityT]):
key = config_entry.entry_id
if key in self._platforms:
raise ValueError("Config entry has already been setup!")
raise ValueError(
f"Config entry {config_entry.title} ({key}) for "
f"{platform_type}.{self.domain} has already been setup!"
)
self._platforms[key] = self._async_init_entity_platform(
platform_type,

View File

@ -36,7 +36,7 @@ home-assistant-frontend==20240501.1
home-assistant-intents==2024.4.24
httpx==0.27.0
ifaddr==0.2.0
Jinja2==3.1.3
Jinja2==3.1.4
lru-dict==1.3.0
mutagen==1.47.0
orjson==3.9.15

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2024.5.2"
version = "2024.5.3"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"
@ -46,7 +46,7 @@ dependencies = [
"httpx==0.27.0",
"home-assistant-bluetooth==1.12.0",
"ifaddr==0.2.0",
"Jinja2==3.1.3",
"Jinja2==3.1.4",
"lru-dict==1.3.0",
"PyJWT==2.8.0",
# PyJWT has loose dependency. We want the latest one.

View File

@ -22,7 +22,7 @@ hass-nabucasa==0.78.0
httpx==0.27.0
home-assistant-bluetooth==1.12.0
ifaddr==0.2.0
Jinja2==3.1.3
Jinja2==3.1.4
lru-dict==1.3.0
PyJWT==2.8.0
cryptography==42.0.5

View File

@ -697,7 +697,7 @@ debugpy==1.8.1
# decora==0.6
# homeassistant.components.ecovacs
deebot-client==7.1.0
deebot-client==7.2.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@ -952,7 +952,7 @@ glances-api==0.6.0
goalzero==0.2.2
# homeassistant.components.goodwe
goodwe==0.3.4
goodwe==0.3.5
# homeassistant.components.google_mail
# homeassistant.components.google_tasks
@ -1800,7 +1800,7 @@ pyefergy==22.1.1
pyegps==0.2.5
# homeassistant.components.enphase_envoy
pyenphase==1.20.1
pyenphase==1.20.3
# homeassistant.components.envisalink
pyenvisalink==4.6
@ -2337,7 +2337,7 @@ pytradfri[async]==9.0.1
pytrafikverket==0.3.10
# homeassistant.components.v2c
pytrydan==0.4.0
pytrydan==0.6.0
# homeassistant.components.usb
pyudev==0.24.1
@ -2460,7 +2460,7 @@ rjpl==0.3.6
rocketchat-API==0.6.1
# homeassistant.components.roku
rokuecp==0.19.2
rokuecp==0.19.3
# homeassistant.components.romy
romy==0.0.10
@ -2914,7 +2914,7 @@ yeelight==0.7.14
yeelightsunflower==0.0.10
# homeassistant.components.yolink
yolink-api==0.4.3
yolink-api==0.4.4
# homeassistant.components.youless
youless-api==1.0.1

View File

@ -575,7 +575,7 @@ dbus-fast==2.21.1
debugpy==1.8.1
# homeassistant.components.ecovacs
deebot-client==7.1.0
deebot-client==7.2.0
# homeassistant.components.ihc
# homeassistant.components.namecheapdns
@ -781,7 +781,7 @@ glances-api==0.6.0
goalzero==0.2.2
# homeassistant.components.goodwe
goodwe==0.3.4
goodwe==0.3.5
# homeassistant.components.google_mail
# homeassistant.components.google_tasks
@ -1405,7 +1405,7 @@ pyefergy==22.1.1
pyegps==0.2.5
# homeassistant.components.enphase_envoy
pyenphase==1.20.1
pyenphase==1.20.3
# homeassistant.components.everlights
pyeverlights==0.1.0
@ -1816,7 +1816,7 @@ pytradfri[async]==9.0.1
pytrafikverket==0.3.10
# homeassistant.components.v2c
pytrydan==0.4.0
pytrydan==0.6.0
# homeassistant.components.usb
pyudev==0.24.1
@ -1906,7 +1906,7 @@ rflink==0.0.66
ring-doorbell[listen]==0.8.11
# homeassistant.components.roku
rokuecp==0.19.2
rokuecp==0.19.3
# homeassistant.components.romy
romy==0.0.10
@ -2264,7 +2264,7 @@ yalexs==3.0.1
yeelight==0.7.14
# homeassistant.components.yolink
yolink-api==0.4.3
yolink-api==0.4.4
# homeassistant.components.youless
youless-api==1.0.1

View File

@ -3,6 +3,7 @@
from aiohomekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.services import ServicesTypes
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er
@ -94,6 +95,24 @@ def create_window_covering_service_with_v_tilt_2(accessory):
tilt_target.maxValue = 0
def create_window_covering_service_with_none_tilt(accessory):
"""Define a window-covering characteristics as per page 219 of HAP spec.
This accessory uses None for the tilt value unexpectedly.
"""
service = create_window_covering_service(accessory)
tilt_current = service.add_char(CharacteristicsTypes.VERTICAL_TILT_CURRENT)
tilt_current.value = None
tilt_current.minValue = -90
tilt_current.maxValue = 0
tilt_target = service.add_char(CharacteristicsTypes.VERTICAL_TILT_TARGET)
tilt_target.value = None
tilt_target.minValue = -90
tilt_target.maxValue = 0
async def test_change_window_cover_state(hass: HomeAssistant) -> None:
"""Test that we can turn a HomeKit alarm on and off again."""
helper = await setup_test_component(hass, create_window_covering_service)
@ -212,6 +231,21 @@ async def test_read_window_cover_tilt_vertical_2(hass: HomeAssistant) -> None:
assert state.attributes["current_tilt_position"] == 83
async def test_read_window_cover_tilt_missing_tilt(hass: HomeAssistant) -> None:
"""Test that missing tilt is handled."""
helper = await setup_test_component(
hass, create_window_covering_service_with_none_tilt
)
await helper.async_update(
ServicesTypes.WINDOW_COVERING,
{CharacteristicsTypes.OBSTRUCTION_DETECTED: True},
)
state = await helper.poll_and_get_state()
assert "current_tilt_position" not in state.attributes
assert state.state != STATE_UNAVAILABLE
async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant) -> None:
"""Test that horizontal tilt is written correctly."""
helper = await setup_test_component(

View File

@ -6,8 +6,9 @@ from datetime import datetime, timedelta
import json
import socket
import ssl
import time
from typing import Any, TypedDict
from unittest.mock import ANY, MagicMock, call, mock_open, patch
from unittest.mock import ANY, MagicMock, Mock, call, mock_open, patch
from freezegun.api import FrozenDateTimeFactory
import paho.mqtt.client as paho_mqtt
@ -938,6 +939,42 @@ async def test_receiving_non_utf8_message_gets_logged(
)
async def test_receiving_message_with_non_utf8_topic_gets_logged(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
record_calls: MessageCallbackType,
caplog: pytest.LogCaptureFixture,
) -> None:
"""Test receiving a non utf8 encoded topic."""
await mqtt_mock_entry()
await mqtt.async_subscribe(hass, "test-topic", record_calls)
# Local import to avoid processing MQTT modules when running a testcase
# which does not use MQTT.
# pylint: disable-next=import-outside-toplevel
from paho.mqtt.client import MQTTMessage
# pylint: disable-next=import-outside-toplevel
from homeassistant.components.mqtt.models import MqttData
msg = MQTTMessage(topic=b"tasmota/discovery/18FE34E0B760\xcc\x02")
msg.payload = b"Payload"
msg.qos = 2
msg.retain = True
msg.timestamp = time.monotonic()
mqtt_data: MqttData = hass.data["mqtt"]
assert mqtt_data.client
mqtt_data.client._async_mqtt_on_message(Mock(), None, msg)
assert (
"Skipping received retained message on invalid "
"topic b'tasmota/discovery/18FE34E0B760\\xcc\\x02' "
"(qos=2): b'Payload'" in caplog.text
)
async def test_all_subscriptions_run_when_decode_fails(
hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator,
@ -2589,19 +2626,19 @@ async def test_subscription_done_when_birth_message_is_sent(
mqtt_client_mock.on_connect(None, None, 0, 0)
await hass.async_block_till_done()
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await mqtt.async_subscribe(hass, "topic/test", record_calls)
# We wait until we receive a birth message
await asyncio.wait_for(birth.wait(), 1)
# Assert we already have subscribed at the client
# for new config payloads at the time we the birth message is received
assert ("homeassistant/+/+/config", 0) in help_all_subscribe_calls(
mqtt_client_mock
)
assert ("homeassistant/+/+/+/config", 0) in help_all_subscribe_calls(
mqtt_client_mock
)
mqtt_client_mock.publish.assert_called_with(
"homeassistant/status", "online", 0, False
)
# Assert we already have subscribed at the client
# for new config payloads at the time we the birth message is received
subscribe_calls = help_all_subscribe_calls(mqtt_client_mock)
assert ("homeassistant/+/+/config", 0) in subscribe_calls
assert ("homeassistant/+/+/+/config", 0) in subscribe_calls
mqtt_client_mock.publish.assert_called_with(
"homeassistant/status", "online", 0, False
)
assert ("topic/test", 0) in subscribe_calls
@pytest.mark.parametrize(

View File

@ -335,6 +335,9 @@ async def test_default_entity_and_device_name(
# Assert that no issues ware registered
assert len(events) == 0
await hass.async_block_till_done()
# Assert that no issues ware registered
assert len(events) == 0
async def test_name_attribute_is_set_or_not(

View File

@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from soco import SoCo
from soco.alarms import Alarms
from soco.data_structures import DidlFavorite, SearchResult
from soco.events_base import Event as SonosEvent
from homeassistant.components import ssdp, zeroconf
@ -17,7 +18,7 @@ from homeassistant.components.sonos import DOMAIN
from homeassistant.const import CONF_HOSTS
from homeassistant.core import HomeAssistant
from tests.common import MockConfigEntry, load_fixture
from tests.common import MockConfigEntry, load_fixture, load_json_value_fixture
class SonosMockEventListener:
@ -304,6 +305,14 @@ def config_fixture():
return {DOMAIN: {MP_DOMAIN: {CONF_HOSTS: ["192.168.42.2"]}}}
@pytest.fixture(name="sonos_favorites")
def sonos_favorites_fixture() -> SearchResult:
"""Create sonos favorites fixture."""
favorites = load_json_value_fixture("sonos_favorites.json", "sonos")
favorite_list = [DidlFavorite.from_dict(fav) for fav in favorites]
return SearchResult(favorite_list, "favorites", 3, 3, 1)
class MockMusicServiceItem:
"""Mocks a Soco MusicServiceItem."""
@ -408,10 +417,10 @@ def mock_get_music_library_information(
@pytest.fixture(name="music_library")
def music_library_fixture():
def music_library_fixture(sonos_favorites: SearchResult) -> Mock:
"""Create music_library fixture."""
music_library = MagicMock()
music_library.get_sonos_favorites.return_value.update_id = 1
music_library.get_sonos_favorites.return_value = sonos_favorites
music_library.browse_by_idstring = mock_browse_by_idstring
music_library.get_music_library_information = mock_get_music_library_information
return music_library

View File

@ -0,0 +1,38 @@
[
{
"title": "66 - Watercolors",
"parent_id": "FV:2",
"item_id": "FV:2/4",
"resource_meta_data": "<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\" xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\"><item id=\"10090120Api%3atune%3aliveAudio%3ajazzcafe%3ae4b5402c-9999-9999-9999-4bc8e2cdccce\" parentID=\"10086064live%3f93b0b9cb-9999-9999-9999-bcf75971fcfe\" restricted=\"false\"><dc:title>66 - Watercolors</dc:title><upnp:class>object.item.audioItem.audioBroadcast</upnp:class><desc id=\"cdudn\" nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">SA_RINCON9479_X_#Svc9479-99999999-Token</desc></item></DIDL-Lite>",
"resources": [
{
"uri": "x-sonosapi-hls:Api%3atune%3aliveAudio%3ajazzcafe%3aetc",
"protocol_info": "a:b:c:d"
}
]
},
{
"title": "James Taylor Radio",
"parent_id": "FV:2",
"item_id": "FV:2/13",
"resource_meta_data": "<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\" xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\"><item id=\"100c2068ST%3a1683194971234567890\" parentID=\"10fe2064myStations\" restricted=\"true\"><dc:title>James Taylor Radio</dc:title><upnp:class>object.item.audioItem.audioBroadcast.#station</upnp:class><desc id=\"cdudn\" nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">SA_RINCON60423_X_#Svc60423-99999999-Token</desc></item></DIDL-Lite>",
"resources": [
{
"uri": "x-sonosapi-radio:ST%3aetc",
"protocol_info": "a:b:c:d"
}
]
},
{
"title": "1984",
"parent_id": "FV:2",
"item_id": "FV:2/8",
"resource_meta_data": "<DIDL-Lite xmlns:dc=\"http://purl.org/dc/elements/1.1/\" xmlns:upnp=\"urn:schemas-upnp-org:metadata-1-0/upnp/\" xmlns:r=\"urn:schemas-rinconnetworks-com:metadata-1-0/\" xmlns=\"urn:schemas-upnp-org:metadata-1-0/DIDL-Lite/\"><item id=\"A:ALBUMARTIST/Aerosmith/1984\" parentID=\"A:ALBUMARTIST/Aerosmith\" restricted=\"true\"><dc:title>1984</dc:title><upnp:class>object.container.album.musicAlbum</upnp:class><desc id=\"cdudn\" nameSpace=\"urn:schemas-rinconnetworks-com:metadata-1-0/\">RINCON_AssociatedZPUDN</desc></item></DIDL-Lite>",
"resources": [
{
"uri": "x-rincon-playlist:RINCON_test#A:ALBUMARTIST/Aerosmith/1984",
"protocol_info": "a:b:c:d"
}
]
}
]

View File

@ -1,6 +1,7 @@
"""Tests for the Sonos Media Player platform."""
import logging
from typing import Any
import pytest
@ -9,10 +10,15 @@ from homeassistant.components.media_player import (
SERVICE_PLAY_MEDIA,
MediaPlayerEnqueue,
)
from homeassistant.components.media_player.const import ATTR_MEDIA_ENQUEUE
from homeassistant.components.media_player.const import (
ATTR_MEDIA_ENQUEUE,
SERVICE_SELECT_SOURCE,
)
from homeassistant.components.sonos.const import SOURCE_LINEIN, SOURCE_TV
from homeassistant.components.sonos.media_player import LONG_SERVICE_TIMEOUT
from homeassistant.const import STATE_IDLE
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC,
CONNECTION_UPNP,
@ -272,3 +278,154 @@ async def test_play_media_music_library_playlist_dne(
assert soco_mock.play_uri.call_count == 0
assert media_content_id in caplog.text
assert "playlist" in caplog.text
@pytest.mark.parametrize(
("source", "result"),
[
(
SOURCE_LINEIN,
{
"switch_to_line_in": 1,
},
),
(
SOURCE_TV,
{
"switch_to_tv": 1,
},
),
],
)
async def test_select_source_line_in_tv(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
async_autosetup_sonos,
source: str,
result: dict[str, Any],
) -> None:
"""Test the select_source method with a variety of inputs."""
soco_mock = soco_factory.mock_list.get("192.168.42.2")
await hass.services.async_call(
MP_DOMAIN,
SERVICE_SELECT_SOURCE,
{
"entity_id": "media_player.zone_a",
"source": source,
},
blocking=True,
)
assert soco_mock.switch_to_line_in.call_count == result.get("switch_to_line_in", 0)
assert soco_mock.switch_to_tv.call_count == result.get("switch_to_tv", 0)
@pytest.mark.parametrize(
("source", "result"),
[
(
"James Taylor Radio",
{
"play_uri": 1,
"play_uri_uri": "x-sonosapi-radio:ST%3aetc",
"play_uri_title": "James Taylor Radio",
},
),
(
"66 - Watercolors",
{
"play_uri": 1,
"play_uri_uri": "x-sonosapi-hls:Api%3atune%3aliveAudio%3ajazzcafe%3aetc",
"play_uri_title": "66 - Watercolors",
},
),
],
)
async def test_select_source_play_uri(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
async_autosetup_sonos,
source: str,
result: dict[str, Any],
) -> None:
"""Test the select_source method with a variety of inputs."""
soco_mock = soco_factory.mock_list.get("192.168.42.2")
await hass.services.async_call(
MP_DOMAIN,
SERVICE_SELECT_SOURCE,
{
"entity_id": "media_player.zone_a",
"source": source,
},
blocking=True,
)
assert soco_mock.play_uri.call_count == result.get("play_uri")
soco_mock.play_uri.assert_called_with(
result.get("play_uri_uri"),
title=result.get("play_uri_title"),
timeout=LONG_SERVICE_TIMEOUT,
)
@pytest.mark.parametrize(
("source", "result"),
[
(
"1984",
{
"add_to_queue": 1,
"add_to_queue_item_id": "A:ALBUMARTIST/Aerosmith/1984",
"clear_queue": 1,
"play_from_queue": 1,
},
),
],
)
async def test_select_source_play_queue(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
async_autosetup_sonos,
source: str,
result: dict[str, Any],
) -> None:
"""Test the select_source method with a variety of inputs."""
soco_mock = soco_factory.mock_list.get("192.168.42.2")
await hass.services.async_call(
MP_DOMAIN,
SERVICE_SELECT_SOURCE,
{
"entity_id": "media_player.zone_a",
"source": source,
},
blocking=True,
)
assert soco_mock.clear_queue.call_count == result.get("clear_queue")
assert soco_mock.add_to_queue.call_count == result.get("add_to_queue")
assert soco_mock.add_to_queue.call_args_list[0].args[0].item_id == result.get(
"add_to_queue_item_id"
)
assert (
soco_mock.add_to_queue.call_args_list[0].kwargs["timeout"]
== LONG_SERVICE_TIMEOUT
)
assert soco_mock.play_from_queue.call_count == result.get("play_from_queue")
soco_mock.play_from_queue.assert_called_with(0)
async def test_select_source_error(
hass: HomeAssistant,
soco_factory: SoCoMockFactory,
async_autosetup_sonos,
) -> None:
"""Test the select_source method with a variety of inputs."""
with pytest.raises(ServiceValidationError) as sve:
await hass.services.async_call(
MP_DOMAIN,
SERVICE_SELECT_SOURCE,
{
"entity_id": "media_player.zone_a",
"source": "invalid_source",
},
blocking=True,
)
assert "invalid_source" in str(sve.value)
assert "Could not find a Sonos favorite" in str(sve.value)

View File

@ -3,6 +3,7 @@
from collections import OrderedDict
from datetime import timedelta
import logging
import re
from unittest.mock import AsyncMock, Mock, patch
from freezegun import freeze_time
@ -365,7 +366,13 @@ async def test_setup_entry_fails_duplicate(hass: HomeAssistant) -> None:
assert await component.async_setup_entry(entry)
with pytest.raises(ValueError):
with pytest.raises(
ValueError,
match=re.escape(
f"Config entry Mock Title ({entry.entry_id}) for "
"entry_domain.test_domain has already been setup!"
),
):
await component.async_setup_entry(entry)