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

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/ecovacs", "documentation": "https://www.home-assistant.io/integrations/ecovacs",
"iot_class": "cloud_push", "iot_class": "cloud_push",
"loggers": ["sleekxmppfs", "sucks", "deebot_client"], "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", "documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["pyenphase"], "loggers": ["pyenphase"],
"requirements": ["pyenphase==1.20.1"], "requirements": ["pyenphase==1.20.3"],
"zeroconf": [ "zeroconf": [
{ {
"type": "_enphase-envoy._tcp.local." "type": "_enphase-envoy._tcp.local."

View File

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/goodwe", "documentation": "https://www.home-assistant.io/integrations/goodwe",
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["goodwe"], "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_unit_of_measurement=PERCENTAGE,
native_step=1, native_step=1,
native_min_value=0, native_min_value=0,
native_max_value=100, native_max_value=200,
getter=lambda inv: inv.get_grid_export_limit(), getter=lambda inv: inv.get_grid_export_limit(),
setter=lambda inv, val: inv.set_grid_export_limit(val), setter=lambda inv, val: inv.set_grid_export_limit(val),
filter=lambda inv: _get_setting_unit(inv, "grid_export_limit") == "%", filter=lambda inv: _get_setting_unit(inv, "grid_export_limit") == "%",

View File

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

View File

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

View File

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

View File

@ -1015,8 +1015,7 @@ class MqttDiscoveryUpdate(Entity):
self.hass.async_create_task( self.hass.async_create_task(
_async_process_discovery_update_and_remove( _async_process_discovery_update_and_remove(
payload, self._discovery_data payload, self._discovery_data
), )
eager_start=False,
) )
elif self._discovery_update: elif self._discovery_update:
if old_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD]: if old_payload != self._discovery_data[ATTR_DISCOVERY_PAYLOAD]:
@ -1025,8 +1024,7 @@ class MqttDiscoveryUpdate(Entity):
self.hass.async_create_task( self.hass.async_create_task(
_async_process_discovery_update( _async_process_discovery_update(
payload, self._discovery_update, self._discovery_data payload, self._discovery_update, self._discovery_data
), )
eager_start=False,
) )
else: else:
# Non-empty, unchanged payload: Ignore to avoid changing states # Non-empty, unchanged payload: Ignore to avoid changing states
@ -1059,6 +1057,15 @@ class MqttDiscoveryUpdate(Entity):
# rediscovered after a restart # rediscovered after a restart
await async_remove_discovery_payload(self.hass, self._discovery_data) 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 @callback
def add_to_platform_abort(self) -> None: def add_to_platform_abort(self) -> None:
"""Abort adding an entity to a platform.""" """Abort adding an entity to a platform."""
@ -1218,8 +1225,6 @@ class MqttEntity(
self._prepare_subscribe_topics() self._prepare_subscribe_topics()
await self._subscribe_topics() await self._subscribe_topics()
await self.mqtt_async_added_to_hass() 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: async def mqtt_async_added_to_hass(self) -> None:
"""Call before the discovery message is acknowledged. """Call before the discovery message is acknowledged.

View File

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

View File

@ -11,7 +11,7 @@
"iot_class": "local_polling", "iot_class": "local_polling",
"loggers": ["rokuecp"], "loggers": ["rokuecp"],
"quality_scale": "silver", "quality_scale": "silver",
"requirements": ["rokuecp==0.19.2"], "requirements": ["rokuecp==0.19.3"],
"ssdp": [ "ssdp": [
{ {
"st": "roku:ecp", "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.config_entries import ConfigEntry
from homeassistant.const import ATTR_TIME from homeassistant.const import ATTR_TIME
from homeassistant.core import HomeAssistant, ServiceCall, callback 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 import config_validation as cv, entity_platform, service
from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.helpers.dispatcher import async_dispatcher_connect
from homeassistant.helpers.entity_platform import AddEntitiesCallback 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] fav = [fav for fav in self.speaker.favorites if fav.title == name]
if len(fav) != 1: if len(fav) != 1:
return raise ServiceValidationError(
translation_domain=SONOS_DOMAIN,
translation_key="invalid_favorite",
translation_placeholders={
"name": name,
},
)
src = fav.pop() src = fav.pop()
self._play_favorite(src) self._play_favorite(src)
@ -445,7 +451,7 @@ class SonosMediaPlayerEntity(SonosEntity, MediaPlayerEntity):
MUSIC_SRC_RADIO, MUSIC_SRC_RADIO,
MUSIC_SRC_LINE_IN, MUSIC_SRC_LINE_IN,
]: ]:
soco.play_uri(uri, title=favorite.title) soco.play_uri(uri, title=favorite.title, timeout=LONG_SERVICE_TIMEOUT)
else: else:
soco.clear_queue() soco.clear_queue()
soco.add_to_queue(favorite.reference, timeout=LONG_SERVICE_TIMEOUT) 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, "config_flow": true,
"documentation": "https://www.home-assistant.io/integrations/v2c", "documentation": "https://www.home-assistant.io/integrations/v2c",
"iot_class": "local_polling", "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_fan": "Button Fan \"{subtype}\"",
"button_swing": "Button Swing \"{subtype}\"", "button_swing": "Button Swing \"{subtype}\"",
"button_decrease_speed": "Button Decrease Speed \"{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_stop": "Button Stop \"{subtype}\"",
"button_light": "Button Light \"{subtype}\"", "button_light": "Button Light \"{subtype}\"",
"button_wind_speed": "Button Wind Speed \"{subtype}\"", "button_wind_speed": "Button Wind Speed \"{subtype}\"",

View File

@ -6,5 +6,5 @@
"dependencies": ["auth", "application_credentials"], "dependencies": ["auth", "application_credentials"],
"documentation": "https://www.home-assistant.io/integrations/yolink", "documentation": "https://www.home-assistant.io/integrations/yolink",
"iot_class": "cloud_push", "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" APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024 MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 5 MINOR_VERSION: Final = 5
PATCH_VERSION: Final = "2" PATCH_VERSION: Final = "3"
__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

@ -182,7 +182,10 @@ class EntityComponent(Generic[_EntityT]):
key = config_entry.entry_id key = config_entry.entry_id
if key in self._platforms: 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( self._platforms[key] = self._async_init_entity_platform(
platform_type, platform_type,

View File

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

View File

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project] [project]
name = "homeassistant" name = "homeassistant"
version = "2024.5.2" version = "2024.5.3"
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"
@ -46,7 +46,7 @@ dependencies = [
"httpx==0.27.0", "httpx==0.27.0",
"home-assistant-bluetooth==1.12.0", "home-assistant-bluetooth==1.12.0",
"ifaddr==0.2.0", "ifaddr==0.2.0",
"Jinja2==3.1.3", "Jinja2==3.1.4",
"lru-dict==1.3.0", "lru-dict==1.3.0",
"PyJWT==2.8.0", "PyJWT==2.8.0",
# PyJWT has loose dependency. We want the latest one. # PyJWT has loose dependency. We want the latest one.

View File

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

View File

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

View File

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

View File

@ -3,6 +3,7 @@
from aiohomekit.model.characteristics import CharacteristicsTypes from aiohomekit.model.characteristics import CharacteristicsTypes
from aiohomekit.model.services import ServicesTypes from aiohomekit.model.services import ServicesTypes
from homeassistant.const import STATE_UNAVAILABLE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.helpers import entity_registry as er 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 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: async def test_change_window_cover_state(hass: HomeAssistant) -> None:
"""Test that we can turn a HomeKit alarm on and off again.""" """Test that we can turn a HomeKit alarm on and off again."""
helper = await setup_test_component(hass, create_window_covering_service) 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 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: async def test_write_window_cover_tilt_horizontal(hass: HomeAssistant) -> None:
"""Test that horizontal tilt is written correctly.""" """Test that horizontal tilt is written correctly."""
helper = await setup_test_component( helper = await setup_test_component(

View File

@ -6,8 +6,9 @@ from datetime import datetime, timedelta
import json import json
import socket import socket
import ssl import ssl
import time
from typing import Any, TypedDict 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 from freezegun.api import FrozenDateTimeFactory
import paho.mqtt.client as paho_mqtt 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( async def test_all_subscriptions_run_when_decode_fails(
hass: HomeAssistant, hass: HomeAssistant,
mqtt_mock_entry: MqttMockHAClientGenerator, 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) mqtt_client_mock.on_connect(None, None, 0, 0)
await hass.async_block_till_done() await hass.async_block_till_done()
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED) hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
await mqtt.async_subscribe(hass, "topic/test", record_calls)
# We wait until we receive a birth message # We wait until we receive a birth message
await asyncio.wait_for(birth.wait(), 1) 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 we already have subscribed at the client
assert ("homeassistant/+/+/config", 0) in help_all_subscribe_calls( # for new config payloads at the time we the birth message is received
mqtt_client_mock subscribe_calls = help_all_subscribe_calls(mqtt_client_mock)
) assert ("homeassistant/+/+/config", 0) in subscribe_calls
assert ("homeassistant/+/+/+/config", 0) in help_all_subscribe_calls( assert ("homeassistant/+/+/+/config", 0) in subscribe_calls
mqtt_client_mock mqtt_client_mock.publish.assert_called_with(
) "homeassistant/status", "online", 0, False
mqtt_client_mock.publish.assert_called_with( )
"homeassistant/status", "online", 0, False assert ("topic/test", 0) in subscribe_calls
)
@pytest.mark.parametrize( @pytest.mark.parametrize(

View File

@ -335,6 +335,9 @@ async def test_default_entity_and_device_name(
# Assert that no issues ware registered # Assert that no issues ware registered
assert len(events) == 0 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( 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 import pytest
from soco import SoCo from soco import SoCo
from soco.alarms import Alarms from soco.alarms import Alarms
from soco.data_structures import DidlFavorite, SearchResult
from soco.events_base import Event as SonosEvent from soco.events_base import Event as SonosEvent
from homeassistant.components import ssdp, zeroconf from homeassistant.components import ssdp, zeroconf
@ -17,7 +18,7 @@ from homeassistant.components.sonos import DOMAIN
from homeassistant.const import CONF_HOSTS from homeassistant.const import CONF_HOSTS
from homeassistant.core import HomeAssistant 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: class SonosMockEventListener:
@ -304,6 +305,14 @@ def config_fixture():
return {DOMAIN: {MP_DOMAIN: {CONF_HOSTS: ["192.168.42.2"]}}} 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: class MockMusicServiceItem:
"""Mocks a Soco MusicServiceItem.""" """Mocks a Soco MusicServiceItem."""
@ -408,10 +417,10 @@ def mock_get_music_library_information(
@pytest.fixture(name="music_library") @pytest.fixture(name="music_library")
def music_library_fixture(): def music_library_fixture(sonos_favorites: SearchResult) -> Mock:
"""Create music_library fixture.""" """Create music_library fixture."""
music_library = MagicMock() 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.browse_by_idstring = mock_browse_by_idstring
music_library.get_music_library_information = mock_get_music_library_information music_library.get_music_library_information = mock_get_music_library_information
return music_library 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.""" """Tests for the Sonos Media Player platform."""
import logging import logging
from typing import Any
import pytest import pytest
@ -9,10 +10,15 @@ from homeassistant.components.media_player import (
SERVICE_PLAY_MEDIA, SERVICE_PLAY_MEDIA,
MediaPlayerEnqueue, 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.components.sonos.media_player import LONG_SERVICE_TIMEOUT
from homeassistant.const import STATE_IDLE from homeassistant.const import STATE_IDLE
from homeassistant.core import HomeAssistant from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ServiceValidationError
from homeassistant.helpers.device_registry import ( from homeassistant.helpers.device_registry import (
CONNECTION_NETWORK_MAC, CONNECTION_NETWORK_MAC,
CONNECTION_UPNP, CONNECTION_UPNP,
@ -272,3 +278,154 @@ async def test_play_media_music_library_playlist_dne(
assert soco_mock.play_uri.call_count == 0 assert soco_mock.play_uri.call_count == 0
assert media_content_id in caplog.text assert media_content_id in caplog.text
assert "playlist" 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 collections import OrderedDict
from datetime import timedelta from datetime import timedelta
import logging import logging
import re
from unittest.mock import AsyncMock, Mock, patch from unittest.mock import AsyncMock, Mock, patch
from freezegun import freeze_time 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) 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) await component.async_setup_entry(entry)