diff --git a/CODEOWNERS b/CODEOWNERS index 5a130d0278bd20..47ab063477a02c 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -953,6 +953,8 @@ build.json @home-assistant/supervisor /tests/components/met_eireann/ @DylanGore /homeassistant/components/meteo_france/ @hacf-fr @oncleben31 @Quentame /tests/components/meteo_france/ @hacf-fr @oncleben31 @Quentame +/homeassistant/components/meteo_lt/ @xE1H +/tests/components/meteo_lt/ @xE1H /homeassistant/components/meteoalarm/ @rolfberkenbosch /homeassistant/components/meteoclimatic/ @adrianmo /tests/components/meteoclimatic/ @adrianmo diff --git a/homeassistant/brands/eltako.json b/homeassistant/brands/eltako.json new file mode 100644 index 00000000000000..ead922aa5b24ff --- /dev/null +++ b/homeassistant/brands/eltako.json @@ -0,0 +1,5 @@ +{ + "domain": "eltako", + "name": "Eltako", + "iot_standards": ["matter"] +} diff --git a/homeassistant/brands/konnected.json b/homeassistant/brands/konnected.json new file mode 100644 index 00000000000000..6581fe1e476b9e --- /dev/null +++ b/homeassistant/brands/konnected.json @@ -0,0 +1,5 @@ +{ + "domain": "konnected", + "name": "Konnected", + "integrations": ["konnected", "konnected_esphome"] +} diff --git a/homeassistant/brands/level.json b/homeassistant/brands/level.json new file mode 100644 index 00000000000000..89fe23b502bdfe --- /dev/null +++ b/homeassistant/brands/level.json @@ -0,0 +1,5 @@ +{ + "domain": "level", + "name": "Level", + "iot_standards": ["matter"] +} diff --git a/homeassistant/components/aladdin_connect/config_flow.py b/homeassistant/components/aladdin_connect/config_flow.py index bfc76720454198..dab801d4712227 100644 --- a/homeassistant/components/aladdin_connect/config_flow.py +++ b/homeassistant/components/aladdin_connect/config_flow.py @@ -22,6 +22,17 @@ class OAuth2FlowHandler( VERSION = CONFIG_FLOW_VERSION MINOR_VERSION = CONFIG_FLOW_MINOR_VERSION + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Check we have the cloud integration set up.""" + if "cloud" not in self.hass.config.components: + return self.async_abort( + reason="cloud_not_enabled", + description_placeholders={"default_config": "default_config"}, + ) + return await super().async_step_user(user_input) + async def async_step_reauth( self, user_input: Mapping[str, Any] ) -> ConfigFlowResult: diff --git a/homeassistant/components/aladdin_connect/strings.json b/homeassistant/components/aladdin_connect/strings.json index 7d673efd3cb6ea..c452ba66865b1b 100644 --- a/homeassistant/components/aladdin_connect/strings.json +++ b/homeassistant/components/aladdin_connect/strings.json @@ -24,7 +24,8 @@ "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", - "wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account." + "wrong_account": "You are authenticated with a different account than the one set up. Please authenticate with the configured account.", + "cloud_not_enabled": "Please make sure you run Home Assistant with `{default_config}` enabled in your configuration.yaml." }, "create_entry": { "default": "[%key:common::config_flow::create_entry::authenticated%]" diff --git a/homeassistant/components/ecobee/strings.json b/homeassistant/components/ecobee/strings.json index b121c178e278c1..b5cec2858111c6 100644 --- a/homeassistant/components/ecobee/strings.json +++ b/homeassistant/components/ecobee/strings.json @@ -176,7 +176,7 @@ "description": "Sets the participating sensors for a climate program.", "fields": { "preset_mode": { - "name": "Climate Name", + "name": "Climate program", "description": "Name of the climate program to set the sensors active on.\nDefaults to currently active program." }, "device_ids": { @@ -188,7 +188,7 @@ }, "exceptions": { "invalid_preset": { - "message": "Invalid climate name, available options are: {options}" + "message": "Invalid climate program, available options are: {options}" }, "invalid_sensor": { "message": "Invalid sensor for thermostat, available options are: {options}" diff --git a/homeassistant/components/ecowitt/manifest.json b/homeassistant/components/ecowitt/manifest.json index ba3d01ef6af3ff..d8b8aedbc3d469 100644 --- a/homeassistant/components/ecowitt/manifest.json +++ b/homeassistant/components/ecowitt/manifest.json @@ -6,5 +6,5 @@ "dependencies": ["webhook"], "documentation": "https://www.home-assistant.io/integrations/ecowitt", "iot_class": "local_push", - "requirements": ["aioecowitt==2025.9.1"] + "requirements": ["aioecowitt==2025.9.2"] } diff --git a/homeassistant/components/habitica/__init__.py b/homeassistant/components/habitica/__init__.py index 514a12d26b7467..e9e2ae09350cdb 100644 --- a/homeassistant/components/habitica/__init__.py +++ b/homeassistant/components/habitica/__init__.py @@ -4,9 +4,14 @@ from habiticalib import Habitica +from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN from homeassistant.const import CONF_API_KEY, CONF_URL, CONF_VERIFY_SSL, Platform from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv, device_registry as dr +from homeassistant.helpers import ( + config_validation as cv, + device_registry as dr, + entity_registry as er, +) from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType from homeassistant.util.hass_dict import HassKey @@ -27,6 +32,7 @@ Platform.BUTTON, Platform.CALENDAR, Platform.IMAGE, + Platform.NOTIFY, Platform.SENSOR, Platform.SWITCH, Platform.TODO, @@ -46,6 +52,7 @@ async def async_setup_entry( """Set up habitica from a config entry.""" party_added_by_this_entry: UUID | None = None device_reg = dr.async_get(hass) + entity_registry = er.async_get(hass) session = async_get_clientsession( hass, verify_ssl=config_entry.data.get(CONF_VERIFY_SSL, True) @@ -96,6 +103,15 @@ def _party_update_listener() -> None: device.id, remove_config_entry_id=config_entry.entry_id ) + notify_entities = [ + entry.entity_id + for entry in entity_registry.entities.values() + if entry.domain == NOTIFY_DOMAIN + and entry.config_entry_id == config_entry.entry_id + ] + for entity_id in notify_entities: + entity_registry.async_remove(entity_id) + hass.config_entries.async_schedule_reload(config_entry.entry_id) coordinator.async_add_listener(_party_update_listener) diff --git a/homeassistant/components/habitica/binary_sensor.py b/homeassistant/components/habitica/binary_sensor.py index 662611ad2a8f29..10464acaf17c38 100644 --- a/homeassistant/components/habitica/binary_sensor.py +++ b/homeassistant/components/habitica/binary_sensor.py @@ -121,4 +121,4 @@ def __init__( @property def is_on(self) -> bool | None: """If the binary sensor is on.""" - return self.coordinator.data.quest.active + return self.coordinator.data.party.quest.active diff --git a/homeassistant/components/habitica/coordinator.py b/homeassistant/components/habitica/coordinator.py index d9376820b16f9f..94de7cc152385d 100644 --- a/homeassistant/components/habitica/coordinator.py +++ b/homeassistant/components/habitica/coordinator.py @@ -9,6 +9,7 @@ from io import BytesIO import logging from typing import Any +from uuid import UUID from aiohttp import ClientError from habiticalib import ( @@ -48,6 +49,14 @@ class HabiticaData: tasks: list[TaskData] +@dataclass +class HabiticaPartyData: + """Habitica party data.""" + + party: GroupData + members: dict[UUID, UserData] + + type HabiticaConfigEntry = ConfigEntry[HabiticaDataUpdateCoordinator] @@ -192,11 +201,19 @@ async def generate_avatar(self, avatar: Avatar) -> bytes: return png.getvalue() -class HabiticaPartyCoordinator(HabiticaBaseCoordinator[GroupData]): +class HabiticaPartyCoordinator(HabiticaBaseCoordinator[HabiticaPartyData]): """Habitica Party Coordinator.""" _update_interval = timedelta(minutes=15) - async def _update_data(self) -> GroupData: + async def _update_data(self) -> HabiticaPartyData: """Fetch the latest party data.""" - return (await self.habitica.get_group()).data + + return HabiticaPartyData( + party=(await self.habitica.get_group()).data, + members={ + member.id: member + for member in (await self.habitica.get_group_members()).data + if member.id + }, + ) diff --git a/homeassistant/components/habitica/entity.py b/homeassistant/components/habitica/entity.py index fa227fec334988..4d82815956b9c4 100644 --- a/homeassistant/components/habitica/entity.py +++ b/homeassistant/components/habitica/entity.py @@ -68,14 +68,14 @@ def __init__( super().__init__(coordinator) if TYPE_CHECKING: assert config_entry.unique_id - unique_id = f"{config_entry.unique_id}_{coordinator.data.id!s}" + unique_id = f"{config_entry.unique_id}_{coordinator.data.party.id!s}" self.entity_description = entity_description self._attr_unique_id = f"{unique_id}_{entity_description.key}" self._attr_device_info = DeviceInfo( entry_type=DeviceEntryType.SERVICE, manufacturer=MANUFACTURER, model=NAME, - name=coordinator.data.summary, + name=coordinator.data.party.summary, identifiers={(DOMAIN, unique_id)}, via_device=(DOMAIN, config_entry.unique_id), ) diff --git a/homeassistant/components/habitica/icons.json b/homeassistant/components/habitica/icons.json index 0b5d4aaa682c47..9b77606f557921 100644 --- a/homeassistant/components/habitica/icons.json +++ b/homeassistant/components/habitica/icons.json @@ -194,6 +194,11 @@ "quest_running": { "default": "mdi:script-text-play" } + }, + "notify": { + "party_chat": { + "default": "mdi:forum" + } } }, "services": { diff --git a/homeassistant/components/habitica/image.py b/homeassistant/components/habitica/image.py index f064074ea0ac86..15efc8e6667f23 100644 --- a/homeassistant/components/habitica/image.py +++ b/homeassistant/components/habitica/image.py @@ -128,7 +128,7 @@ def image_url(self) -> str | None: """Return URL of image.""" return ( f"{ASSETS_URL}quest_{key}.png" - if (key := self.coordinator.data.quest.key) + if (key := self.coordinator.data.party.quest.key) else None ) diff --git a/homeassistant/components/habitica/notify.py b/homeassistant/components/habitica/notify.py new file mode 100644 index 00000000000000..8a29ac1d641cb2 --- /dev/null +++ b/homeassistant/components/habitica/notify.py @@ -0,0 +1,202 @@ +"""Notify platform for the Habitica integration.""" + +from __future__ import annotations + +from abc import abstractmethod +from enum import StrEnum +from typing import TYPE_CHECKING +from uuid import UUID + +from aiohttp import ClientError +from habiticalib import ( + GroupData, + HabiticaException, + NotAuthorizedError, + NotFoundError, + TooManyRequestsError, + UserData, +) + +from homeassistant.components.notify import ( + DOMAIN as NOTIFY_DOMAIN, + NotifyEntity, + NotifyEntityDescription, +) +from homeassistant.const import CONF_NAME +from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import HABITICA_KEY +from .const import DOMAIN +from .coordinator import HabiticaConfigEntry, HabiticaDataUpdateCoordinator +from .entity import HabiticaBase + +PARALLEL_UPDATES = 10 + + +class HabiticaNotify(StrEnum): + """Habitica Notifier.""" + + PARTY_CHAT = "party_chat" + PRIVATE_MESSAGE = "private_message" + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: HabiticaConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the notify entity platform.""" + members_added: set[UUID] = set() + entity_registry = er.async_get(hass) + + coordinator = config_entry.runtime_data + + if party := coordinator.data.user.party.id: + party_coordinator = hass.data[HABITICA_KEY][party] + async_add_entities( + [HabiticaPartyChatNotifyEntity(coordinator, party_coordinator.data.party)] + ) + + @callback + def add_entities() -> None: + nonlocal members_added + + new_members = set(party_coordinator.data.members.keys()) - members_added + if TYPE_CHECKING: + assert coordinator.data.user.id + new_members.discard(coordinator.data.user.id) + if new_members: + async_add_entities( + HabiticaPrivateMessageNotifyEntity( + coordinator, party_coordinator.data.members[member] + ) + for member in new_members + ) + members_added |= new_members + + delete_members = members_added - set(party_coordinator.data.members.keys()) + for member in delete_members: + if entity_id := entity_registry.async_get_entity_id( + NOTIFY_DOMAIN, + DOMAIN, + f"{coordinator.config_entry.unique_id}_{member!s}_{HabiticaNotify.PRIVATE_MESSAGE}", + ): + entity_registry.async_remove(entity_id) + + members_added.discard(member) + + party_coordinator.async_add_listener(add_entities) + add_entities() + + +class HabiticaBaseNotifyEntity(HabiticaBase, NotifyEntity): + """Habitica base notify entity.""" + + def __init__( + self, + coordinator: HabiticaDataUpdateCoordinator, + ) -> None: + """Initialize a Habitica entity.""" + super().__init__(coordinator, self.entity_description) + + @abstractmethod + async def _send_message(self, message: str) -> None: + """Send a Habitica message.""" + + async def async_send_message(self, message: str, title: str | None = None) -> None: + """Send a message.""" + try: + await self._send_message(message) + except NotAuthorizedError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_forbidden", + translation_placeholders={ + **self.translation_placeholders, + "reason": e.error.message, + }, + ) from e + except NotFoundError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="send_message_not_found", + translation_placeholders={ + **self.translation_placeholders, + "reason": e.error.message, + }, + ) from e + except TooManyRequestsError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="setup_rate_limit_exception", + translation_placeholders={"retry_after": str(e.retry_after)}, + ) from e + except HabiticaException as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": e.error.message}, + ) from e + except ClientError as e: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="service_call_exception", + translation_placeholders={"reason": str(e)}, + ) from e + + +class HabiticaPartyChatNotifyEntity(HabiticaBaseNotifyEntity): + """Representation of a Habitica party chat notify entity.""" + + def __init__( + self, + coordinator: HabiticaDataUpdateCoordinator, + party: GroupData, + ) -> None: + """Initialize a Habitica entity.""" + self._attr_translation_placeholders = {CONF_NAME: party.name} + + self.entity_description = NotifyEntityDescription( + key=HabiticaNotify.PARTY_CHAT, + translation_key=HabiticaNotify.PARTY_CHAT, + ) + self.party = party + super().__init__(coordinator) + + async def _send_message(self, message: str) -> None: + """Send a Habitica party chat message.""" + + await self.coordinator.habitica.send_group_message( + message=message, + group_id=self.party.id, + ) + + +class HabiticaPrivateMessageNotifyEntity(HabiticaBaseNotifyEntity): + """Representation of a Habitica private message notify entity.""" + + def __init__( + self, + coordinator: HabiticaDataUpdateCoordinator, + member: UserData, + ) -> None: + """Initialize a Habitica entity.""" + self._attr_translation_placeholders = {CONF_NAME: member.profile.name or ""} + self.entity_description = NotifyEntityDescription( + key=f"{member.id!s}_{HabiticaNotify.PRIVATE_MESSAGE}", + translation_key=HabiticaNotify.PRIVATE_MESSAGE, + ) + self.member = member + super().__init__(coordinator) + + async def _send_message(self, message: str) -> None: + """Send a Habitica private message.""" + if TYPE_CHECKING: + assert self.member.id + await self.coordinator.habitica.send_private_message( + message=message, + to_user_id=self.member.id, + ) diff --git a/homeassistant/components/habitica/sensor.py b/homeassistant/components/habitica/sensor.py index 385e1e8d1f4de4..a13594e6f4bcf4 100644 --- a/homeassistant/components/habitica/sensor.py +++ b/homeassistant/components/habitica/sensor.py @@ -445,7 +445,9 @@ class HabiticaPartySensor(HabiticaPartyBase, SensorEntity): def native_value(self) -> StateType: """Return the state of the device.""" - return self.entity_description.value_fn(self.coordinator.data, self.content) + return self.entity_description.value_fn( + self.coordinator.data.party, self.content + ) @property def entity_picture(self) -> str | None: @@ -453,7 +455,9 @@ def entity_picture(self) -> str | None: pic = self.entity_description.entity_picture entity_picture = ( - pic if isinstance(pic, str) or pic is None else pic(self.coordinator.data) + pic + if isinstance(pic, str) or pic is None + else pic(self.coordinator.data.party) ) return ( @@ -468,5 +472,5 @@ def entity_picture(self) -> str | None: def extra_state_attributes(self) -> dict[str, Any] | None: """Return entity specific state attributes.""" if func := self.entity_description.attributes_fn: - return func(self.coordinator.data, self.content) + return func(self.coordinator.data.party, self.content) return None diff --git a/homeassistant/components/habitica/strings.json b/homeassistant/components/habitica/strings.json index 335eacc05e9b6c..57c5fee55b650a 100644 --- a/homeassistant/components/habitica/strings.json +++ b/homeassistant/components/habitica/strings.json @@ -264,6 +264,14 @@ "name": "[%key:component::habitica::common::quest_name%]" } }, + "notify": { + "party_chat": { + "name": "Party chat" + }, + "private_message": { + "name": "Private message: {name}" + } + }, "sensor": { "display_name": { "name": "Display name", @@ -572,6 +580,12 @@ }, "frequency_not_monthly": { "message": "Unable to update task, monthly repeat settings apply only to monthly recurring dailies." + }, + "send_message_forbidden": { + "message": "You are not allowed to send messages to {name}. ({reason})" + }, + "send_message_not_found": { + "message": "Unable to send message, {name} not found. ({reason})" } }, "issues": { diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 5e480f8440d2f4..20b817fe2c50f2 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -155,34 +155,6 @@ async def async_step_pick_firmware( description_placeholders=self._get_translation_placeholders(), ) - async def _probe_firmware_info( - self, - probe_methods: tuple[ApplicationType, ...] = ( - # We probe in order of frequency: Zigbee, Thread, then multi-PAN - ApplicationType.GECKO_BOOTLOADER, - ApplicationType.EZSP, - ApplicationType.SPINEL, - ApplicationType.CPC, - ), - ) -> bool: - """Probe the firmware currently on the device.""" - assert self._device is not None - - self._probed_firmware_info = await probe_silabs_firmware_info( - self._device, - probe_methods=probe_methods, - ) - - return ( - self._probed_firmware_info is not None - and self._probed_firmware_info.firmware_type - in ( - ApplicationType.EZSP, - ApplicationType.SPINEL, - ApplicationType.CPC, - ) - ) - async def _install_firmware_step( self, fw_update_url: str, @@ -236,12 +208,6 @@ async def _install_firmware( expected_installed_firmware_type: ApplicationType, ) -> None: """Install firmware.""" - if not await self._probe_firmware_info(): - raise AbortFlow( - reason="unsupported_firmware", - description_placeholders=self._get_translation_placeholders(), - ) - assert self._device is not None # Keep track of the firmware we're working with, for error messages @@ -250,6 +216,8 @@ async def _install_firmware( # Installing new firmware is only truly required if the wrong type is # installed: upgrading to the latest release of the current firmware type # isn't strictly necessary for functionality. + self._probed_firmware_info = await probe_silabs_firmware_info(self._device) + firmware_install_required = self._probed_firmware_info is None or ( self._probed_firmware_info.firmware_type != expected_installed_firmware_type ) @@ -301,7 +269,7 @@ async def _install_firmware( # Otherwise, fail raise AbortFlow(reason="firmware_download_failed") from err - await async_flash_silabs_firmware( + self._probed_firmware_info = await async_flash_silabs_firmware( hass=self.hass, device=self._device, fw_data=fw_data, @@ -314,15 +282,6 @@ async def _install_firmware( async def _configure_and_start_otbr_addon(self) -> None: """Configure and start the OTBR addon.""" - - # Before we start the addon, confirm that the correct firmware is running - # and populate `self._probed_firmware_info` with the correct information - if not await self._probe_firmware_info(probe_methods=(ApplicationType.SPINEL,)): - raise AbortFlow( - "unsupported_firmware", - description_placeholders=self._get_translation_placeholders(), - ) - otbr_manager = get_otbr_addon_manager(self.hass) addon_info = await self._async_get_addon_info(otbr_manager) @@ -444,12 +403,12 @@ async def _async_continue_picked_firmware(self) -> ConfigFlowResult: if self._picked_firmware_type == PickedFirmwareType.ZIGBEE: return await self.async_step_install_zigbee_firmware() - return await self.async_step_prepare_thread_installation() + return await self.async_step_install_thread_firmware() - async def async_step_prepare_thread_installation( + async def async_step_finish_thread_installation( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Prepare for Thread installation by stopping the OTBR addon if needed.""" + """Finish Thread installation by starting the OTBR addon.""" if not is_hassio(self.hass): return self.async_abort( reason="not_hassio_thread", @@ -459,22 +418,12 @@ async def async_step_prepare_thread_installation( otbr_manager = get_otbr_addon_manager(self.hass) addon_info = await self._async_get_addon_info(otbr_manager) - if addon_info.state == AddonState.RUNNING: - # Stop the addon before continuing to flash firmware - await otbr_manager.async_stop_addon() - - return await self.async_step_install_thread_firmware() - - async def async_step_finish_thread_installation( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Finish Thread installation by starting the OTBR addon.""" - otbr_manager = get_otbr_addon_manager(self.hass) - addon_info = await self._async_get_addon_info(otbr_manager) - if addon_info.state == AddonState.NOT_INSTALLED: return await self.async_step_install_otbr_addon() + if addon_info.state == AddonState.RUNNING: + await otbr_manager.async_stop_addon() + return await self.async_step_start_otbr_addon() async def async_step_pick_firmware_zigbee( @@ -511,12 +460,6 @@ async def async_step_continue_zigbee( assert self._device is not None assert self._hardware_name is not None - if not await self._probe_firmware_info(probe_methods=(ApplicationType.EZSP,)): - return self.async_abort( - reason="unsupported_firmware", - description_placeholders=self._get_translation_placeholders(), - ) - if self._zigbee_integration == ZigbeeIntegration.OTHER: return self._async_flow_finished() diff --git a/homeassistant/components/homeassistant_hardware/util.py b/homeassistant/components/homeassistant_hardware/util.py index d84f4f75ff7083..d3bddad97545a3 100644 --- a/homeassistant/components/homeassistant_hardware/util.py +++ b/homeassistant/components/homeassistant_hardware/util.py @@ -42,9 +42,9 @@ class ApplicationType(StrEnum): """Application type running on a device.""" GECKO_BOOTLOADER = "bootloader" - CPC = "cpc" EZSP = "ezsp" SPINEL = "spinel" + CPC = "cpc" ROUTER = "router" @classmethod diff --git a/homeassistant/components/homeassistant_yellow/config_flow.py b/homeassistant/components/homeassistant_yellow/config_flow.py index efc218caeaa71e..8339a3562b33a7 100644 --- a/homeassistant/components/homeassistant_yellow/config_flow.py +++ b/homeassistant/components/homeassistant_yellow/config_flow.py @@ -27,6 +27,7 @@ from homeassistant.components.homeassistant_hardware.util import ( ApplicationType, FirmwareInfo, + probe_silabs_firmware_info, ) from homeassistant.config_entries import ( SOURCE_HARDWARE, @@ -141,8 +142,10 @@ async def async_step_system( self, data: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" + assert self._device is not None + # We do not actually use any portion of `BaseFirmwareConfigFlow` beyond this - await self._probe_firmware_info() + self._probed_firmware_info = await probe_silabs_firmware_info(self._device) # Kick off ZHA hardware discovery automatically if Zigbee firmware is running if ( diff --git a/homeassistant/components/konnected_esphome/__init__.py b/homeassistant/components/konnected_esphome/__init__.py new file mode 100644 index 00000000000000..376c1b26c78089 --- /dev/null +++ b/homeassistant/components/konnected_esphome/__init__.py @@ -0,0 +1 @@ +"""Virtual integration: Konnected ESPHome.""" diff --git a/homeassistant/components/konnected_esphome/manifest.json b/homeassistant/components/konnected_esphome/manifest.json new file mode 100644 index 00000000000000..0c9827c80e61e1 --- /dev/null +++ b/homeassistant/components/konnected_esphome/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "konnected_esphome", + "name": "Konnected", + "integration_type": "virtual", + "supported_by": "esphome" +} diff --git a/homeassistant/components/lg_thinq/icons.json b/homeassistant/components/lg_thinq/icons.json index b384370be647e0..527480a9065a0a 100644 --- a/homeassistant/components/lg_thinq/icons.json +++ b/homeassistant/components/lg_thinq/icons.json @@ -282,9 +282,24 @@ "filter_lifetime": { "default": "mdi:air-filter" }, + "top_filter_remain_percent": { + "default": "mdi:air-filter" + }, "used_time": { "default": "mdi:air-filter" }, + "water_filter_state": { + "default": "mdi:air-filter" + }, + "water_filter_1_remain_percent": { + "default": "mdi:air-filter" + }, + "water_filter_2_remain_percent": { + "default": "mdi:air-filter" + }, + "water_filter_3_remain_percent": { + "default": "mdi:air-filter" + }, "current_job_mode": { "default": "mdi:dots-circle" }, diff --git a/homeassistant/components/lg_thinq/sensor.py b/homeassistant/components/lg_thinq/sensor.py index 2161504b902a46..578611952baf70 100644 --- a/homeassistant/components/lg_thinq/sensor.py +++ b/homeassistant/components/lg_thinq/sensor.py @@ -110,6 +110,11 @@ native_unit_of_measurement=PERCENTAGE, translation_key=ThinQProperty.FILTER_LIFETIME, ), + ThinQProperty.TOP_FILTER_REMAIN_PERCENT: SensorEntityDescription( + key=ThinQProperty.TOP_FILTER_REMAIN_PERCENT, + native_unit_of_measurement=PERCENTAGE, + translation_key=ThinQProperty.TOP_FILTER_REMAIN_PERCENT, + ), } HUMIDITY_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { ThinQProperty.CURRENT_HUMIDITY: SensorEntityDescription( @@ -221,6 +226,11 @@ device_class=SensorDeviceClass.ENUM, translation_key=ThinQProperty.FRESH_AIR_FILTER, ), + ThinQProperty.FRESH_AIR_FILTER_REMAIN_PERCENT: SensorEntityDescription( + key=ThinQProperty.FRESH_AIR_FILTER_REMAIN_PERCENT, + native_unit_of_measurement=PERCENTAGE, + translation_key=ThinQProperty.FRESH_AIR_FILTER, + ), } RUN_STATE_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { ThinQProperty.CURRENT_STATE: SensorEntityDescription( @@ -303,6 +313,25 @@ native_unit_of_measurement=UnitOfTime.MONTHS, translation_key=ThinQProperty.USED_TIME, ), + ThinQProperty.WATER_FILTER_STATE: SensorEntityDescription( + key=ThinQProperty.WATER_FILTER_STATE, + translation_key=ThinQProperty.WATER_FILTER_STATE, + ), + ThinQProperty.WATER_FILTER_1_REMAIN_PERCENT: SensorEntityDescription( + key=ThinQProperty.WATER_FILTER_1_REMAIN_PERCENT, + native_unit_of_measurement=PERCENTAGE, + translation_key=ThinQProperty.WATER_FILTER_1_REMAIN_PERCENT, + ), + ThinQProperty.WATER_FILTER_2_REMAIN_PERCENT: SensorEntityDescription( + key=ThinQProperty.WATER_FILTER_2_REMAIN_PERCENT, + native_unit_of_measurement=PERCENTAGE, + translation_key=ThinQProperty.WATER_FILTER_2_REMAIN_PERCENT, + ), + ThinQProperty.WATER_FILTER_3_REMAIN_PERCENT: SensorEntityDescription( + key=ThinQProperty.WATER_FILTER_3_REMAIN_PERCENT, + native_unit_of_measurement=PERCENTAGE, + translation_key=ThinQProperty.WATER_FILTER_3_REMAIN_PERCENT, + ), } WATER_INFO_SENSOR_DESC: dict[ThinQProperty, SensorEntityDescription] = { ThinQProperty.WATER_TYPE: SensorEntityDescription( @@ -437,6 +466,7 @@ AIR_QUALITY_SENSOR_DESC[ThinQProperty.ODOR_LEVEL], AIR_QUALITY_SENSOR_DESC[ThinQProperty.TOTAL_POLLUTION_LEVEL], FILTER_INFO_SENSOR_DESC[ThinQProperty.FILTER_REMAIN_PERCENT], + FILTER_INFO_SENSOR_DESC[ThinQProperty.TOP_FILTER_REMAIN_PERCENT], JOB_MODE_SENSOR_DESC[ThinQProperty.CURRENT_JOB_MODE], JOB_MODE_SENSOR_DESC[ThinQProperty.PERSONALIZATION_MODE], TIME_SENSOR_DESC[TimerProperty.ABSOLUTE_TO_START], @@ -513,7 +543,12 @@ ), DeviceType.REFRIGERATOR: ( REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER], + REFRIGERATION_SENSOR_DESC[ThinQProperty.FRESH_AIR_FILTER_REMAIN_PERCENT], WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.USED_TIME], + WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.WATER_FILTER_STATE], + WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.WATER_FILTER_1_REMAIN_PERCENT], + WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.WATER_FILTER_2_REMAIN_PERCENT], + WATER_FILTER_INFO_SENSOR_DESC[ThinQProperty.WATER_FILTER_3_REMAIN_PERCENT], ), DeviceType.ROBOT_CLEANER: ( RUN_STATE_SENSOR_DESC[ThinQProperty.CURRENT_STATE], diff --git a/homeassistant/components/lg_thinq/strings.json b/homeassistant/components/lg_thinq/strings.json index 9758585c6e4086..bb90b668d4ea1c 100644 --- a/homeassistant/components/lg_thinq/strings.json +++ b/homeassistant/components/lg_thinq/strings.json @@ -241,7 +241,9 @@ "timer_is_complete": "Timer has been completed", "washing_is_complete": "Washing is completed", "water_is_full": "Water is full", - "water_leak_has_occurred": "The dishwasher has detected a water leak" + "water_leak_has_occurred": "The dishwasher has detected a water leak", + "filter_reset_complete": "The filter lifetime has been reset", + "water_filter_reset_complete": "The water filter lifetime has been reset" } } } @@ -608,9 +610,24 @@ "filter_lifetime": { "name": "Filter remaining" }, + "top_filter_remain_percent": { + "name": "Upper filter remaining" + }, "used_time": { "name": "Water filter used" }, + "water_filter_state": { + "name": "Water filter" + }, + "water_filter_1_remain_percent": { + "name": "[%key:component::lg_thinq::entity::sensor::water_filter_state::name%]" + }, + "water_filter_2_remain_percent": { + "name": "Water filter stage 2" + }, + "water_filter_3_remain_percent": { + "name": "Water filter stage 3" + }, "current_job_mode": { "name": "Operating mode", "state": { diff --git a/homeassistant/components/litterrobot/update.py b/homeassistant/components/litterrobot/update.py index 4d9dfe5074df35..8f3a176175b043 100644 --- a/homeassistant/components/litterrobot/update.py +++ b/homeassistant/components/litterrobot/update.py @@ -26,6 +26,7 @@ key="firmware", device_class=UpdateDeviceClass.FIRMWARE, ) +RELEASE_URL = "https://www.litter-robot.com/releases.html" async def async_setup_entry( @@ -48,6 +49,7 @@ async def async_setup_entry( class RobotUpdateEntity(LitterRobotEntity[LitterRobot4], UpdateEntity): """A class that describes robot update entities.""" + _attr_release_url = RELEASE_URL _attr_supported_features = ( UpdateEntityFeature.INSTALL | UpdateEntityFeature.PROGRESS ) diff --git a/homeassistant/components/media_player/intent.py b/homeassistant/components/media_player/intent.py index c45dc83e872ece..2cca51af4ad66f 100644 --- a/homeassistant/components/media_player/intent.py +++ b/homeassistant/components/media_player/intent.py @@ -14,6 +14,7 @@ SERVICE_MEDIA_PAUSE, SERVICE_MEDIA_PLAY, SERVICE_MEDIA_PREVIOUS_TRACK, + SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, STATE_PLAYING, ) @@ -27,6 +28,7 @@ from .const import ( ATTR_MEDIA_FILTER_CLASSES, ATTR_MEDIA_VOLUME_LEVEL, + ATTR_MEDIA_VOLUME_MUTED, DOMAIN, SERVICE_PLAY_MEDIA, SERVICE_SEARCH_MEDIA, @@ -39,6 +41,8 @@ INTENT_MEDIA_UNPAUSE = "HassMediaUnpause" INTENT_MEDIA_NEXT = "HassMediaNext" INTENT_MEDIA_PREVIOUS = "HassMediaPrevious" +INTENT_PLAYER_MUTE = "HassMediaPlayerMute" +INTENT_PLAYER_UNMUTE = "HassMediaPlayerUnmute" INTENT_SET_VOLUME = "HassSetVolume" INTENT_SET_VOLUME_RELATIVE = "HassSetVolumeRelative" INTENT_MEDIA_SEARCH_AND_PLAY = "HassMediaSearchAndPlay" @@ -130,6 +134,8 @@ async def async_setup_intents(hass: HomeAssistant) -> None: ), ) intent.async_register(hass, MediaSetVolumeRelativeHandler()) + intent.async_register(hass, MediaPlayerMuteUnmuteHandler(True)) + intent.async_register(hass, MediaPlayerMuteUnmuteHandler(False)) intent.async_register(hass, MediaSearchAndPlayHandler()) @@ -231,6 +237,42 @@ async def async_handle_states( ) +class MediaPlayerMuteUnmuteHandler(intent.ServiceIntentHandler): + """Handle Mute/Unmute intents.""" + + def __init__(self, is_volume_muted: bool) -> None: + """Initialize the mute/unmute handler objects.""" + + super().__init__( + (INTENT_PLAYER_MUTE if is_volume_muted else INTENT_PLAYER_UNMUTE), + DOMAIN, + SERVICE_VOLUME_MUTE, + required_domains={DOMAIN}, + required_features=MediaPlayerEntityFeature.VOLUME_MUTE, + optional_slots={ + ATTR_MEDIA_VOLUME_MUTED: intent.IntentSlotInfo( + description="Whether the media player should be muted or unmuted", + value_schema=vol.Boolean(), + ), + }, + description=( + "Mutes a media player" if is_volume_muted else "Unmutes a media player" + ), + platforms={DOMAIN}, + device_classes={MediaPlayerDeviceClass}, + ) + self.is_volume_muted = is_volume_muted + + async def async_handle(self, intent_obj: intent.Intent) -> intent.IntentResponse: + """Handle the intent.""" + + intent_obj.slots["is_volume_muted"] = { + "value": self.is_volume_muted, + "text": str(self.is_volume_muted), + } + return await super().async_handle(intent_obj) + + class MediaSearchAndPlayHandler(intent.IntentHandler): """Handle HassMediaSearchAndPlay intents.""" diff --git a/homeassistant/components/meteo_lt/__init__.py b/homeassistant/components/meteo_lt/__init__.py new file mode 100644 index 00000000000000..8e508e76203c6a --- /dev/null +++ b/homeassistant/components/meteo_lt/__init__.py @@ -0,0 +1,27 @@ +"""The Meteo.lt integration.""" + +from __future__ import annotations + +from homeassistant.core import HomeAssistant + +from .const import CONF_PLACE_CODE, PLATFORMS +from .coordinator import MeteoLtConfigEntry, MeteoLtUpdateCoordinator + + +async def async_setup_entry(hass: HomeAssistant, entry: MeteoLtConfigEntry) -> bool: + """Set up Meteo.lt from a config entry.""" + + coordinator = MeteoLtUpdateCoordinator(hass, entry.data[CONF_PLACE_CODE], entry) + + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: MeteoLtConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/meteo_lt/config_flow.py b/homeassistant/components/meteo_lt/config_flow.py new file mode 100644 index 00000000000000..b9478e8b37eb4c --- /dev/null +++ b/homeassistant/components/meteo_lt/config_flow.py @@ -0,0 +1,78 @@ +"""Config flow for Meteo.lt integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +import aiohttp +from meteo_lt import MeteoLtAPI, Place +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult + +from .const import CONF_PLACE_CODE, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class MeteoLtConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Meteo.lt.""" + + def __init__(self) -> None: + """Initialize the config flow.""" + self._api = MeteoLtAPI() + self._places: list[Place] = [] + self._selected_place: Place | None = None + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + + if user_input is not None: + place_code = user_input[CONF_PLACE_CODE] + self._selected_place = next( + (place for place in self._places if place.code == place_code), + None, + ) + if self._selected_place: + await self.async_set_unique_id(self._selected_place.code) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=self._selected_place.name, + data={ + CONF_PLACE_CODE: self._selected_place.code, + }, + ) + errors["base"] = "invalid_location" + + if not self._places: + try: + await self._api.fetch_places() + self._places = self._api.places + except (aiohttp.ClientError, TimeoutError) as err: + _LOGGER.error("Error fetching places: %s", err) + return self.async_abort(reason="cannot_connect") + + if not self._places: + return self.async_abort(reason="no_places_found") + + places_options = { + place.code: f"{place.name} ({place.administrative_division})" + for place in self._places + } + + data_schema = vol.Schema( + { + vol.Required(CONF_PLACE_CODE): vol.In(places_options), + } + ) + + return self.async_show_form( + step_id="user", + data_schema=data_schema, + errors=errors, + ) diff --git a/homeassistant/components/meteo_lt/const.py b/homeassistant/components/meteo_lt/const.py new file mode 100644 index 00000000000000..96aee80b15e2ba --- /dev/null +++ b/homeassistant/components/meteo_lt/const.py @@ -0,0 +1,17 @@ +"""Constants for the Meteo.lt integration.""" + +from datetime import timedelta + +from homeassistant.const import Platform + +DOMAIN = "meteo_lt" +PLATFORMS = [Platform.WEATHER] + +MANUFACTURER = "Lithuanian Hydrometeorological Service" +MODEL = "Weather Station" + +DEFAULT_UPDATE_INTERVAL = timedelta(minutes=30) + +CONF_PLACE_CODE = "place_code" + +ATTRIBUTION = "Data provided by Lithuanian Hydrometeorological Service (LHMT)" diff --git a/homeassistant/components/meteo_lt/coordinator.py b/homeassistant/components/meteo_lt/coordinator.py new file mode 100644 index 00000000000000..12044f6fe78c67 --- /dev/null +++ b/homeassistant/components/meteo_lt/coordinator.py @@ -0,0 +1,61 @@ +"""DataUpdateCoordinator for Meteo.lt integration.""" + +from __future__ import annotations + +import logging + +import aiohttp +from meteo_lt import Forecast as MeteoLtForecast, MeteoLtAPI + +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DEFAULT_UPDATE_INTERVAL, DOMAIN + +_LOGGER = logging.getLogger(__name__) + +type MeteoLtConfigEntry = ConfigEntry[MeteoLtUpdateCoordinator] + + +class MeteoLtUpdateCoordinator(DataUpdateCoordinator[MeteoLtForecast]): + """Class to manage fetching Meteo.lt data.""" + + def __init__( + self, + hass: HomeAssistant, + place_code: str, + config_entry: MeteoLtConfigEntry, + ) -> None: + """Initialize the coordinator.""" + self.client = MeteoLtAPI() + self.place_code = place_code + + super().__init__( + hass, + _LOGGER, + name=DOMAIN, + update_interval=DEFAULT_UPDATE_INTERVAL, + config_entry=config_entry, + ) + + async def _async_update_data(self) -> MeteoLtForecast: + """Fetch data from Meteo.lt API.""" + try: + forecast = await self.client.get_forecast(self.place_code) + except aiohttp.ClientResponseError as err: + raise UpdateFailed( + f"API returned error status {err.status}: {err.message}" + ) from err + except aiohttp.ClientConnectionError as err: + raise UpdateFailed(f"Cannot connect to API: {err}") from err + except (aiohttp.ClientError, TimeoutError) as err: + raise UpdateFailed(f"Error communicating with API: {err}") from err + + # Check if forecast data is available + if not forecast.forecast_timestamps: + raise UpdateFailed( + f"No forecast data available for {self.place_code} - API returned empty timestamps" + ) + + return forecast diff --git a/homeassistant/components/meteo_lt/manifest.json b/homeassistant/components/meteo_lt/manifest.json new file mode 100644 index 00000000000000..9bd97f4574c5bc --- /dev/null +++ b/homeassistant/components/meteo_lt/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "meteo_lt", + "name": "Meteo.lt", + "codeowners": ["@xE1H"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/meteo_lt", + "integration_type": "service", + "iot_class": "cloud_polling", + "quality_scale": "bronze", + "requirements": ["meteo-lt-pkg==0.2.4"] +} diff --git a/homeassistant/components/meteo_lt/quality_scale.yaml b/homeassistant/components/meteo_lt/quality_scale.yaml new file mode 100644 index 00000000000000..52b6505412f1b9 --- /dev/null +++ b/homeassistant/components/meteo_lt/quality_scale.yaml @@ -0,0 +1,86 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom service actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not provide custom service actions to document. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: Weather entities do not require event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: Integration does not register custom service actions. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: todo + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: + status: exempt + comment: Public weather service that does not require authentication. + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: Integration does not support discovery. + discovery: + status: exempt + comment: Weather stations cannot be automatically discovered. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: Single weather entity per config entry, no dynamic device addition. + entity-category: + status: exempt + comment: Weather entities are primary entities and do not require categories. + entity-device-class: + status: exempt + comment: Weather entities have implicit device class from the platform. + entity-disabled-by-default: + status: exempt + comment: Primary weather entity should be enabled by default. + entity-translations: todo + exception-translations: todo + icon-translations: + status: exempt + comment: Weather entities use standard condition-based icons. + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: No dynamic device management required. + + # Platinum + async-dependency: done + inject-websession: todo + strict-typing: todo diff --git a/homeassistant/components/meteo_lt/strings.json b/homeassistant/components/meteo_lt/strings.json new file mode 100644 index 00000000000000..9289961f01c7fb --- /dev/null +++ b/homeassistant/components/meteo_lt/strings.json @@ -0,0 +1,25 @@ +{ + "config": { + "step": { + "user": { + "title": "Select station", + "data": { + "place_code": "Station" + }, + "data_description": { + "place_code": "Weather station to get data from" + } + } + }, + "error": { + "cannot_connect": "Failed to connect to Meteo.lt API", + "invalid_location": "Selected station is invalid", + "unknown": "Unexpected error occurred" + }, + "abort": { + "already_configured": "Station is already configured", + "cannot_connect": "Failed to connect to Meteo.lt API", + "no_places_found": "No stations found from the API" + } + } +} diff --git a/homeassistant/components/meteo_lt/weather.py b/homeassistant/components/meteo_lt/weather.py new file mode 100644 index 00000000000000..902a899dbc3f05 --- /dev/null +++ b/homeassistant/components/meteo_lt/weather.py @@ -0,0 +1,190 @@ +"""Weather platform for Meteo.lt integration.""" + +from __future__ import annotations + +from collections import defaultdict +from datetime import datetime +from typing import Any + +from homeassistant.components.weather import ( + Forecast, + WeatherEntity, + WeatherEntityFeature, +) +from homeassistant.const import ( + UnitOfPrecipitationDepth, + UnitOfPressure, + UnitOfSpeed, + UnitOfTemperature, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceEntryType, DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ATTRIBUTION, DOMAIN, MANUFACTURER, MODEL +from .coordinator import MeteoLtConfigEntry, MeteoLtUpdateCoordinator + + +async def async_setup_entry( + hass: HomeAssistant, + entry: MeteoLtConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the weather platform.""" + coordinator = entry.runtime_data + + async_add_entities([MeteoLtWeatherEntity(coordinator)]) + + +class MeteoLtWeatherEntity(CoordinatorEntity[MeteoLtUpdateCoordinator], WeatherEntity): + """Weather entity for Meteo.lt.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_attribution = ATTRIBUTION + _attr_native_temperature_unit = UnitOfTemperature.CELSIUS + _attr_native_precipitation_unit = UnitOfPrecipitationDepth.MILLIMETERS + _attr_native_pressure_unit = UnitOfPressure.HPA + _attr_native_wind_speed_unit = UnitOfSpeed.METERS_PER_SECOND + _attr_supported_features = ( + WeatherEntityFeature.FORECAST_DAILY | WeatherEntityFeature.FORECAST_HOURLY + ) + + def __init__(self, coordinator: MeteoLtUpdateCoordinator) -> None: + """Initialize the weather entity.""" + super().__init__(coordinator) + + self._place_code = coordinator.place_code + self._attr_unique_id = str(self._place_code) + + self._attr_device_info = DeviceInfo( + entry_type=DeviceEntryType.SERVICE, + identifiers={(DOMAIN, self._place_code)}, + manufacturer=MANUFACTURER, + model=MODEL, + ) + + @property + def native_temperature(self) -> float | None: + """Return the temperature.""" + return self.coordinator.data.current_conditions.temperature + + @property + def native_apparent_temperature(self) -> float | None: + """Return the apparent temperature.""" + return self.coordinator.data.current_conditions.apparent_temperature + + @property + def humidity(self) -> int | None: + """Return the humidity.""" + return self.coordinator.data.current_conditions.humidity + + @property + def native_pressure(self) -> float | None: + """Return the pressure.""" + return self.coordinator.data.current_conditions.pressure + + @property + def native_wind_speed(self) -> float | None: + """Return the wind speed.""" + return self.coordinator.data.current_conditions.wind_speed + + @property + def wind_bearing(self) -> int | None: + """Return the wind bearing.""" + return self.coordinator.data.current_conditions.wind_bearing + + @property + def native_wind_gust_speed(self) -> float | None: + """Return the wind gust speed.""" + return self.coordinator.data.current_conditions.wind_gust_speed + + @property + def cloud_coverage(self) -> int | None: + """Return the cloud coverage.""" + return self.coordinator.data.current_conditions.cloud_coverage + + @property + def condition(self) -> str | None: + """Return the current condition.""" + return self.coordinator.data.current_conditions.condition + + def _convert_forecast_data( + self, forecast_data: Any, include_templow: bool = False + ) -> Forecast: + """Convert forecast timestamp data to Forecast object.""" + return Forecast( + datetime=forecast_data.datetime, + native_temperature=forecast_data.temperature, + native_templow=forecast_data.temperature_low if include_templow else None, + native_apparent_temperature=forecast_data.apparent_temperature, + condition=forecast_data.condition, + native_precipitation=forecast_data.precipitation, + precipitation_probability=None, # Not provided by API + native_wind_speed=forecast_data.wind_speed, + wind_bearing=forecast_data.wind_bearing, + cloud_coverage=forecast_data.cloud_coverage, + ) + + async def async_forecast_daily(self) -> list[Forecast] | None: + """Return the daily forecast.""" + # Using hourly data to create daily summaries, since daily data is not provided directly + if not self.coordinator.data: + return None + + forecasts_by_date = defaultdict(list) + for timestamp in self.coordinator.data.forecast_timestamps: + date = datetime.fromisoformat(timestamp.datetime).date() + forecasts_by_date[date].append(timestamp) + + daily_forecasts = [] + for date in sorted(forecasts_by_date.keys())[:5]: + day_forecasts = forecasts_by_date[date] + if not day_forecasts: + continue + + temps = [ + ts.temperature for ts in day_forecasts if ts.temperature is not None + ] + max_temp = max(temps) if temps else None + min_temp = min(temps) if temps else None + + midday_forecast = min( + day_forecasts, + key=lambda ts: abs(datetime.fromisoformat(ts.datetime).hour - 12), + ) + + daily_forecast = Forecast( + datetime=day_forecasts[0].datetime, + native_temperature=max_temp, + native_templow=min_temp, + native_apparent_temperature=midday_forecast.apparent_temperature, + condition=midday_forecast.condition, + # Calculate precipitation: sum if any values, else None + native_precipitation=( + sum( + ts.precipitation + for ts in day_forecasts + if ts.precipitation is not None + ) + if any(ts.precipitation is not None for ts in day_forecasts) + else None + ), + precipitation_probability=None, + native_wind_speed=midday_forecast.wind_speed, + wind_bearing=midday_forecast.wind_bearing, + cloud_coverage=midday_forecast.cloud_coverage, + ) + daily_forecasts.append(daily_forecast) + + return daily_forecasts + + async def async_forecast_hourly(self) -> list[Forecast] | None: + """Return the hourly forecast.""" + if not self.coordinator.data: + return None + return [ + self._convert_forecast_data(forecast_data) + for forecast_data in self.coordinator.data.forecast_timestamps[:24] + ] diff --git a/homeassistant/components/ntfy/manifest.json b/homeassistant/components/ntfy/manifest.json index 6809f9aafd4e9c..95e0a7857c9508 100644 --- a/homeassistant/components/ntfy/manifest.json +++ b/homeassistant/components/ntfy/manifest.json @@ -6,6 +6,6 @@ "documentation": "https://www.home-assistant.io/integrations/ntfy", "iot_class": "cloud_push", "loggers": ["aionfty"], - "quality_scale": "bronze", + "quality_scale": "platinum", "requirements": ["aiontfy==0.6.0"] } diff --git a/homeassistant/components/ntfy/quality_scale.yaml b/homeassistant/components/ntfy/quality_scale.yaml index b00cdb93c97040..6168628c2b74c0 100644 --- a/homeassistant/components/ntfy/quality_scale.yaml +++ b/homeassistant/components/ntfy/quality_scale.yaml @@ -3,9 +3,7 @@ rules: action-setup: status: exempt comment: only entity actions - appropriate-polling: - status: exempt - comment: the integration does not poll + appropriate-polling: done brands: done common-modules: done config-flow-test-coverage: done @@ -40,26 +38,28 @@ rules: # Gold devices: done diagnostics: done - discovery-update-info: todo - discovery: todo - docs-data-update: todo - docs-examples: todo - docs-known-limitations: todo - docs-supported-devices: todo - docs-supported-functions: todo - docs-troubleshooting: todo - docs-use-cases: todo - dynamic-devices: todo - entity-category: done - entity-device-class: + discovery-update-info: status: exempt - comment: no suitable device class for the notify entity - entity-disabled-by-default: + comment: the service cannot be discovered + discovery: status: exempt - comment: only one entity - entity-translations: + comment: the service cannot be discovered + docs-data-update: done + docs-examples: done + docs-known-limitations: done + docs-supported-devices: status: exempt - comment: the notify entity uses the device name as entity name, no translation required + comment: the integration is a service + docs-supported-functions: done + docs-troubleshooting: done + docs-use-cases: done + dynamic-devices: + status: exempt + comment: devices are added manually as subentries + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done exception-translations: done icon-translations: done reconfiguration-flow: done diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py index ad57e66186d60b..79f7c02e4ba8be 100644 --- a/homeassistant/components/portainer/__init__.py +++ b/homeassistant/components/portainer/__init__.py @@ -57,4 +57,9 @@ async def async_migrate_entry(hass: HomeAssistant, entry: PortainerConfigEntry) data[CONF_API_TOKEN] = data.pop(CONF_API_KEY) hass.config_entries.async_update_entry(entry=entry, data=data, version=2) + if entry.version < 3: + data = dict(entry.data) + data[CONF_VERIFY_SSL] = True + hass.config_entries.async_update_entry(entry=entry, data=data, version=3) + return True diff --git a/homeassistant/components/reolink/icons.json b/homeassistant/components/reolink/icons.json index e4c270ae02b996..13775b5c58fae2 100644 --- a/homeassistant/components/reolink/icons.json +++ b/homeassistant/components/reolink/icons.json @@ -178,9 +178,18 @@ "floodlight_brightness": { "default": "mdi:spotlight-beam" }, + "floodlight_event_brightness": { + "default": "mdi:spotlight-beam" + }, "ir_brightness": { "default": "mdi:led-off" }, + "floodlight_event_on_time": { + "default": "mdi:spotlight-beam" + }, + "floodlight_event_flash_time": { + "default": "mdi:spotlight-beam" + }, "volume": { "default": "mdi:volume-high", "state": { @@ -339,6 +348,9 @@ "floodlight_mode": { "default": "mdi:spotlight-beam" }, + "floodlight_event_mode": { + "default": "mdi:spotlight-beam" + }, "day_night_mode": { "default": "mdi:theme-light-dark" }, diff --git a/homeassistant/components/reolink/number.py b/homeassistant/components/reolink/number.py index 6daea02529685f..eee0dab81fe299 100644 --- a/homeassistant/components/reolink/number.py +++ b/homeassistant/components/reolink/number.py @@ -125,6 +125,22 @@ class ReolinkChimeNumberEntityDescription( value=lambda api, ch: api.whiteled_brightness(ch), method=lambda api, ch, value: api.set_whiteled(ch, brightness=int(value)), ), + ReolinkNumberEntityDescription( + key="floodlight_event_brightness", + cmd_key="GetWhiteLed", + cmd_id=[289, 438], + translation_key="floodlight_event_brightness", + entity_category=EntityCategory.CONFIG, + entity_registry_enabled_default=False, + native_step=1, + native_min_value=1, + native_max_value=100, + supported=lambda api, ch: api.supported(ch, "floodlight_event"), + value=lambda api, ch: api.whiteled_event_brightness(ch), + method=lambda api, ch, value: ( + api.baichuan.set_floodlight(ch, event_brightness=int(value)) + ), + ), ReolinkNumberEntityDescription( key="ir_brightness", cmd_key="208", @@ -139,6 +155,42 @@ class ReolinkChimeNumberEntityDescription( api.baichuan.set_status_led(ch, ir_brightness=int(value)) ), ), + ReolinkNumberEntityDescription( + key="floodlight_event_on_time", + cmd_key="GetWhiteLed", + cmd_id=[289, 438], + translation_key="floodlight_event_on_time", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + entity_registry_enabled_default=False, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=30, + native_max_value=900, + supported=lambda api, ch: api.supported(ch, "floodlight_event"), + value=lambda api, ch: api.whiteled_event_on_time(ch), + method=lambda api, ch, value: ( + api.baichuan.set_floodlight(ch, event_on_time=int(value)) + ), + ), + ReolinkNumberEntityDescription( + key="floodlight_event_flash_time", + cmd_key="GetWhiteLed", + cmd_id=[289, 438], + translation_key="floodlight_event_flash_time", + entity_category=EntityCategory.CONFIG, + device_class=NumberDeviceClass.DURATION, + entity_registry_enabled_default=False, + native_step=1, + native_unit_of_measurement=UnitOfTime.SECONDS, + native_min_value=10, + native_max_value=30, + supported=lambda api, ch: api.supported(ch, "floodlight_event"), + value=lambda api, ch: api.whiteled_event_flash_time(ch), + method=lambda api, ch, value: ( + api.baichuan.set_floodlight(ch, event_flash_time=int(value)) + ), + ), ReolinkNumberEntityDescription( key="volume", cmd_key="GetAudioCfg", diff --git a/homeassistant/components/reolink/select.py b/homeassistant/components/reolink/select.py index 4ce7866625d77b..fc7f6e49eb5c4c 100644 --- a/homeassistant/components/reolink/select.py +++ b/homeassistant/components/reolink/select.py @@ -16,6 +16,7 @@ HDREnum, Host, HubToneEnum, + SpotlightEventModeEnum, SpotlightModeEnum, StatusLedEnum, TrackMethodEnum, @@ -86,6 +87,7 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: ReolinkSelectEntityDescription( key="floodlight_mode", cmd_key="GetWhiteLed", + cmd_id=[289, 438], translation_key="floodlight_mode", entity_category=EntityCategory.CONFIG, get_options=lambda api, ch: api.whiteled_mode_list(ch), @@ -93,6 +95,21 @@ def _get_quick_reply_id(api: Host, ch: int, mess: str) -> int: value=lambda api, ch: SpotlightModeEnum(api.whiteled_mode(ch)).name, method=lambda api, ch, name: api.set_whiteled(ch, mode=name), ), + ReolinkSelectEntityDescription( + key="floodlight_event_mode", + cmd_key="GetWhiteLed", + cmd_id=[289, 438], + translation_key="floodlight_event_mode", + entity_category=EntityCategory.CONFIG, + get_options=[mode.name for mode in SpotlightEventModeEnum], + supported=lambda api, ch: api.supported(ch, "floodlight_event"), + value=lambda api, ch: SpotlightEventModeEnum(api.whiteled_event_mode(ch)).name, + method=lambda api, ch, name: ( + api.baichuan.set_floodlight( + ch, event_mode=SpotlightEventModeEnum[name].value + ) + ), + ), ReolinkSelectEntityDescription( key="day_night_mode", cmd_key="GetIsp", diff --git a/homeassistant/components/reolink/strings.json b/homeassistant/components/reolink/strings.json index 89a62ad90b662b..d9bcc80406f3e7 100644 --- a/homeassistant/components/reolink/strings.json +++ b/homeassistant/components/reolink/strings.json @@ -542,9 +542,18 @@ "floodlight_brightness": { "name": "Floodlight turn on brightness" }, + "floodlight_event_brightness": { + "name": "Floodlight event brightness" + }, "ir_brightness": { "name": "Infrared light brightness" }, + "floodlight_event_on_time": { + "name": "Floodlight event on time" + }, + "floodlight_event_flash_time": { + "name": "Floodlight event flash time" + }, "volume": { "name": "Volume" }, @@ -696,6 +705,14 @@ "autoadaptive": "Auto adaptive" } }, + "floodlight_event_mode": { + "name": "Floodlight event mode", + "state": { + "off": "[%key:common::state::off%]", + "on": "[%key:common::state::on%]", + "flash": "Flash" + } + }, "day_night_mode": { "name": "Day night mode", "state": { diff --git a/homeassistant/components/roomba/entity.py b/homeassistant/components/roomba/entity.py index eb1b36961025c3..71ebab3ae43911 100644 --- a/homeassistant/components/roomba/entity.py +++ b/homeassistant/components/roomba/entity.py @@ -66,6 +66,16 @@ def battery_stats(self): """Return the battery stats.""" return self.vacuum_state.get("bbchg3", {}) + @property + def tank_level(self) -> int | None: + """Return the tank level.""" + return self.vacuum_state.get("tankLvl") + + @property + def dock_tank_level(self) -> int | None: + """Return the dock tank level.""" + return self.vacuum_state.get("dock", {}).get("tankLvl") + @property def last_mission(self): """Return last mission start time.""" diff --git a/homeassistant/components/roomba/icons.json b/homeassistant/components/roomba/icons.json index 8466ecb51e3446..9cf2fdc9836053 100644 --- a/homeassistant/components/roomba/icons.json +++ b/homeassistant/components/roomba/icons.json @@ -35,6 +35,12 @@ }, "last_mission": { "default": "mdi:calendar-clock" + }, + "tank_level": { + "default": "mdi:water" + }, + "dock_tank_level": { + "default": "mdi:water" } } } diff --git a/homeassistant/components/roomba/sensor.py b/homeassistant/components/roomba/sensor.py index ae82424ec34771..803319e0e842e5 100644 --- a/homeassistant/components/roomba/sensor.py +++ b/homeassistant/components/roomba/sensor.py @@ -18,7 +18,7 @@ from homeassistant.helpers.typing import StateType from .const import DOMAIN -from .entity import IRobotEntity +from .entity import IRobotEntity, roomba_reported_state from .models import RoombaData @@ -29,6 +29,16 @@ class RoombaSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[IRobotEntity], StateType] +DOCK_SENSORS: list[RoombaSensorEntityDescription] = [ + RoombaSensorEntityDescription( + key="dock_tank_level", + translation_key="dock_tank_level", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.dock_tank_level, + ), +] + SENSORS: list[RoombaSensorEntityDescription] = [ RoombaSensorEntityDescription( key="battery", @@ -37,6 +47,13 @@ class RoombaSensorEntityDescription(SensorEntityDescription): entity_category=EntityCategory.DIAGNOSTIC, value_fn=lambda self: self.vacuum_state.get("batPct"), ), + RoombaSensorEntityDescription( + key="tank_level", + translation_key="tank_level", + native_unit_of_measurement=PERCENTAGE, + entity_category=EntityCategory.DIAGNOSTIC, + value_fn=lambda self: self.tank_level, + ), RoombaSensorEntityDescription( key="battery_cycles", translation_key="battery_cycles", @@ -132,8 +149,16 @@ async def async_setup_entry( roomba = domain_data.roomba blid = domain_data.blid + sensor_list: list[RoombaSensorEntityDescription] = SENSORS + + has_dock: bool = len(roomba_reported_state(roomba).get("dock", {})) > 0 + + if has_dock: + sensor_list.extend(DOCK_SENSORS) + async_add_entities( - RoombaSensor(roomba, blid, entity_description) for entity_description in SENSORS + RoombaSensor(roomba, blid, entity_description) + for entity_description in sensor_list ) diff --git a/homeassistant/components/roomba/strings.json b/homeassistant/components/roomba/strings.json index 0db70a6a141a04..938c941f238243 100644 --- a/homeassistant/components/roomba/strings.json +++ b/homeassistant/components/roomba/strings.json @@ -23,11 +23,11 @@ } }, "link": { - "title": "Retrieve Password", + "title": "Retrieve password", "description": "Make sure that the iRobot app is not running on any device. Press and hold the Home button (or both Home and Spot buttons) on {name} until the device generates a sound (about two seconds), then submit within 30 seconds." }, "link_manual": { - "title": "Enter Password", + "title": "Enter password", "description": "The password could not be retrieved from the device automatically. Please make sure that the iRobot app is not open on any device while trying to retrieve the password. Please follow the steps outlined in the documentation at: {auth_help_url}", "data": { "password": "[%key:common::config_flow::data::password%]" @@ -90,6 +90,12 @@ }, "last_mission": { "name": "Last mission start time" + }, + "tank_level": { + "name": "Tank level" + }, + "dock_tank_level": { + "name": "Dock tank level" } } } diff --git a/homeassistant/components/roomba/vacuum.py b/homeassistant/components/roomba/vacuum.py index 0c24301f2af741..d955c7a7ecf747 100644 --- a/homeassistant/components/roomba/vacuum.py +++ b/homeassistant/components/roomba/vacuum.py @@ -403,11 +403,16 @@ def extra_state_attributes(self) -> dict[str, Any]: detected_pad = state.get("detectedPad") mop_ready = state.get("mopReady", {}) lid_closed = mop_ready.get("lidClosed") - tank_present = mop_ready.get("tankPresent") + tank_present = mop_ready.get("tankPresent") or state.get("tankPresent") tank_level = state.get("tankLvl") state_attrs[ATTR_DETECTED_PAD] = detected_pad state_attrs[ATTR_LID_CLOSED] = lid_closed state_attrs[ATTR_TANK_PRESENT] = tank_present state_attrs[ATTR_TANK_LEVEL] = tank_level + bin_raw_state = state.get("bin", {}) + if bin_raw_state.get("present") is not None: + state_attrs[ATTR_BIN_PRESENT] = bin_raw_state.get("present") + if bin_raw_state.get("full") is not None: + state_attrs[ATTR_BIN_FULL] = bin_raw_state.get("full") return state_attrs diff --git a/homeassistant/components/sonos/select.py b/homeassistant/components/sonos/select.py index 0a56e37e75c0f0..fa38bf20c9f766 100644 --- a/homeassistant/components/sonos/select.py +++ b/homeassistant/components/sonos/select.py @@ -59,17 +59,12 @@ def available_soco_attributes( for select_data in SELECT_TYPES: if select_data.speaker_model == speaker.model_name.upper(): if ( - state := getattr(speaker.soco, select_data.soco_attribute, None) - ) is not None: - try: - setattr(speaker, select_data.speaker_attribute, int(state)) - features.append(select_data) - except ValueError: - _LOGGER.error( - "Invalid value for %s %s", - select_data.speaker_attribute, - state, - ) + speaker.update_soco_int_attribute( + select_data.soco_attribute, select_data.speaker_attribute + ) + is not None + ): + features.append(select_data) return features async def _async_create_entities(speaker: SonosSpeaker) -> None: @@ -112,8 +107,9 @@ async def _async_fallback_poll(self) -> None: @soco_error() def poll_state(self) -> None: """Poll the device for the current state.""" - state = getattr(self.soco, self.soco_attribute) - setattr(self.speaker, self.speaker_attribute, state) + self.speaker.update_soco_int_attribute( + self.soco_attribute, self.speaker_attribute + ) @property def current_option(self) -> str | None: diff --git a/homeassistant/components/sonos/speaker.py b/homeassistant/components/sonos/speaker.py index acf1b08cd36ed2..c61f047d3e3861 100644 --- a/homeassistant/components/sonos/speaker.py +++ b/homeassistant/components/sonos/speaker.py @@ -275,6 +275,29 @@ def async_write_entity_states(self) -> None: """Write states for associated SonosEntity instances.""" async_dispatcher_send(self.hass, f"{SONOS_STATE_UPDATED}-{self.soco.uid}") + def update_soco_int_attribute( + self, soco_attribute: str, speaker_attribute: str + ) -> int | None: + """Update an integer attribute from SoCo and set it on the speaker. + + Returns the integer value if successful, otherwise None. Do not call from + async context as it is a blocking function. + """ + value: int | None = None + if (state := getattr(self.soco, soco_attribute, None)) is None: + _LOGGER.error("Missing value for %s", speaker_attribute) + else: + try: + value = int(state) + except (TypeError, ValueError): + _LOGGER.error( + "Invalid value for %s %s", + speaker_attribute, + state, + ) + setattr(self, speaker_attribute, value) + return value + # # Properties # diff --git a/homeassistant/components/switchbot_cloud/__init__.py b/homeassistant/components/switchbot_cloud/__init__.py index 1b6ed062563a20..d0fb79ebdde7c4 100644 --- a/homeassistant/components/switchbot_cloud/__init__.py +++ b/homeassistant/components/switchbot_cloud/__init__.py @@ -187,6 +187,15 @@ async def make_device_data( devices_data.buttons.append((device, coordinator)) else: devices_data.switches.append((device, coordinator)) + if isinstance(device, Device) and device.device_type in [ + "Relay Switch 2PM", + ]: + coordinator = await coordinator_for_device( + hass, entry, api, device, coordinators_by_id + ) + devices_data.sensors.append((device, coordinator)) + devices_data.switches.append((device, coordinator)) + if isinstance(device, Device) and device.device_type.startswith("Air Purifier"): coordinator = await coordinator_for_device( hass, entry, api, device, coordinators_by_id diff --git a/homeassistant/components/switchbot_cloud/lock.py b/homeassistant/components/switchbot_cloud/lock.py index 74a9e9d8b1e441..ed852cc742021a 100644 --- a/homeassistant/components/switchbot_cloud/lock.py +++ b/homeassistant/components/switchbot_cloud/lock.py @@ -2,14 +2,14 @@ from typing import Any -from switchbot_api import LockCommands +from switchbot_api import Device, LockCommands, LockV2Commands, Remote, SwitchBotAPI -from homeassistant.components.lock import LockEntity +from homeassistant.components.lock import LockEntity, LockEntityFeature from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from . import SwitchbotCloudData +from . import SwitchbotCloudData, SwitchBotCoordinator from .const import DOMAIN from .entity import SwitchBotCloudEntity @@ -32,10 +32,22 @@ class SwitchBotCloudLock(SwitchBotCloudEntity, LockEntity): _attr_name = None + def __init__( + self, + api: SwitchBotAPI, + device: Device | Remote, + coordinator: SwitchBotCoordinator, + ) -> None: + """Init devices.""" + super().__init__(api, device, coordinator) + self.__model = device.device_type + def _set_attributes(self) -> None: """Set attributes from coordinator data.""" if coord_data := self.coordinator.data: self._attr_is_locked = coord_data["lockState"] == "locked" + if self.__model in LockV2Commands.get_supported_devices(): + self._attr_supported_features = LockEntityFeature.OPEN async def async_lock(self, **kwargs: Any) -> None: """Lock the lock.""" @@ -45,7 +57,12 @@ async def async_lock(self, **kwargs: Any) -> None: async def async_unlock(self, **kwargs: Any) -> None: """Unlock the lock.""" - await self.send_api_command(LockCommands.UNLOCK) self._attr_is_locked = False self.async_write_ha_state() + + async def async_open(self, **kwargs: Any) -> None: + """Latch open the lock.""" + await self.send_api_command(LockV2Commands.DEADBOLT) + self._attr_is_locked = False + self.async_write_ha_state() diff --git a/homeassistant/components/switchbot_cloud/sensor.py b/homeassistant/components/switchbot_cloud/sensor.py index 7e1324717051d6..ff15b980d5e71b 100644 --- a/homeassistant/components/switchbot_cloud/sensor.py +++ b/homeassistant/components/switchbot_cloud/sensor.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from typing import Any -from switchbot_api import Device, SwitchBotAPI +from switchbot_api import Device, Remote, SwitchBotAPI from homeassistant.components.sensor import ( SensorDeviceClass, @@ -22,7 +22,8 @@ UnitOfPower, UnitOfTemperature, ) -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData @@ -41,6 +42,12 @@ SENSOR_TYPE_LIGHTLEVEL = "lightLevel" +RELAY_SWITCH_2PM_SENSOR_TYPE_POWER = "Power" +RELAY_SWITCH_2PM_SENSOR_TYPE_VOLTAGE = "Voltage" +RELAY_SWITCH_2PM_SENSOR_TYPE_CURRENT = "ElectricCurrent" +RELAY_SWITCH_2PM_SENSOR_TYPE_ELECTRICITY = "UsedElectricity" + + @dataclass(frozen=True, kw_only=True) class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): """Plug Mini Eu UsedElectricity Sensor EntityDescription.""" @@ -113,6 +120,34 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): native_unit_of_measurement=CONCENTRATION_PARTS_PER_MILLION, ) +RELAY_SWITCH_2PM_POWER_DESCRIPTION = SensorEntityDescription( + key=RELAY_SWITCH_2PM_SENSOR_TYPE_POWER, + device_class=SensorDeviceClass.POWER, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfPower.WATT, +) + +RELAY_SWITCH_2PM_VOLTAGE_DESCRIPTION = SensorEntityDescription( + key=RELAY_SWITCH_2PM_SENSOR_TYPE_VOLTAGE, + device_class=SensorDeviceClass.VOLTAGE, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricPotential.VOLT, +) + +RELAY_SWITCH_2PM_CURRENT_DESCRIPTION = SensorEntityDescription( + key=RELAY_SWITCH_2PM_SENSOR_TYPE_CURRENT, + device_class=SensorDeviceClass.CURRENT, + state_class=SensorStateClass.MEASUREMENT, + native_unit_of_measurement=UnitOfElectricCurrent.MILLIAMPERE, +) + +RELAY_SWITCH_2PM_ElECTRICITY_DESCRIPTION = SensorEntityDescription( + key=RELAY_SWITCH_2PM_SENSOR_TYPE_ELECTRICITY, + device_class=SensorDeviceClass.ENERGY, + state_class=SensorStateClass.TOTAL_INCREASING, + native_unit_of_measurement=UnitOfEnergy.KILO_WATT_HOUR, +) + LIGHTLEVEL_DESCRIPTION = SensorEntityDescription( key="lightLevel", translation_key="light_level", @@ -175,6 +210,12 @@ class SwitchbotCloudSensorEntityDescription(SensorEntityDescription): "Smart Lock Lite": (BATTERY_DESCRIPTION,), "Smart Lock Pro": (BATTERY_DESCRIPTION,), "Smart Lock Ultra": (BATTERY_DESCRIPTION,), + "Relay Switch 2PM": ( + RELAY_SWITCH_2PM_POWER_DESCRIPTION, + RELAY_SWITCH_2PM_VOLTAGE_DESCRIPTION, + RELAY_SWITCH_2PM_CURRENT_DESCRIPTION, + RELAY_SWITCH_2PM_ElECTRICITY_DESCRIPTION, + ), "Curtain": (BATTERY_DESCRIPTION,), "Curtain3": (BATTERY_DESCRIPTION,), "Roller Shade": (BATTERY_DESCRIPTION,), @@ -203,12 +244,25 @@ async def async_setup_entry( ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] - - async_add_entities( - SwitchBotCloudSensor(data.api, device, coordinator, description) - for device, coordinator in data.devices.sensors - for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type] - ) + entities: list[SwitchBotCloudSensor] = [] + for device, coordinator in data.devices.sensors: + for description in SENSOR_DESCRIPTIONS_BY_DEVICE_TYPES[device.device_type]: + if device.device_type == "Relay Switch 2PM": + entities.append( + SwitchBotCloudRelaySwitch2PMSensor( + data.api, device, coordinator, description, "1" + ) + ) + entities.append( + SwitchBotCloudRelaySwitch2PMSensor( + data.api, device, coordinator, description, "2" + ) + ) + else: + entities.append( + _async_make_entity(data.api, device, coordinator, description) + ) + async_add_entities(entities) class SwitchBotCloudSensor(SwitchBotCloudEntity, SensorEntity): @@ -230,14 +284,49 @@ def _set_attributes(self) -> None: """Set attributes from coordinator data.""" if not self.coordinator.data: return - if isinstance( - self.entity_description, - SwitchbotCloudSensorEntityDescription, - ): - self._attr_native_value = self.entity_description.value_fn( - self.coordinator.data - ) - else: - self._attr_native_value = self.coordinator.data.get( - self.entity_description.key - ) + self._attr_native_value = self.coordinator.data.get(self.entity_description.key) + + +class SwitchBotCloudRelaySwitch2PMSensor(SwitchBotCloudSensor): + """Representation of a SwitchBot Cloud Relay Switch 2PM sensor entity.""" + + def __init__( + self, + api: SwitchBotAPI, + device: Device, + coordinator: SwitchBotCoordinator, + description: SensorEntityDescription, + channel: str, + ) -> None: + """Initialize SwitchBot Cloud sensor entity.""" + super().__init__(api, device, coordinator, description) + + self.entity_description = description + self._channel = channel + self._attr_unique_id = f"{device.device_id}-{description.key}-{channel}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{device.device_name}-channel-{channel}")}, + manufacturer="SwitchBot", + model=device.device_type, + model_id="RelaySwitch2PM", + name=f"{device.device_name} Channel {channel}", + ) + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if not self.coordinator.data: + return + self._attr_native_value = self.coordinator.data.get( + f"switch{self._channel}{self.entity_description.key.strip()}" + ) + + +@callback +def _async_make_entity( + api: SwitchBotAPI, + device: Device | Remote, + coordinator: SwitchBotCoordinator, + description: SensorEntityDescription, +) -> SwitchBotCloudSensor: + """Make a SwitchBotCloudSensor or SwitchBotCloudRelaySwitch2PMSensor.""" + return SwitchBotCloudSensor(api, device, coordinator, description) diff --git a/homeassistant/components/switchbot_cloud/switch.py b/homeassistant/components/switchbot_cloud/switch.py index df21ae12adc3b8..2ca98f928b46de 100644 --- a/homeassistant/components/switchbot_cloud/switch.py +++ b/homeassistant/components/switchbot_cloud/switch.py @@ -1,5 +1,6 @@ """Support for SwitchBot switch.""" +import asyncio from typing import Any from switchbot_api import CommonCommands, Device, PowerState, Remote, SwitchBotAPI @@ -7,10 +8,11 @@ from homeassistant.components.switch import SwitchDeviceClass, SwitchEntity from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import SwitchbotCloudData -from .const import DOMAIN +from .const import AFTER_COMMAND_REFRESH, DOMAIN from .coordinator import SwitchBotCoordinator from .entity import SwitchBotCloudEntity @@ -22,10 +24,19 @@ async def async_setup_entry( ) -> None: """Set up SwitchBot Cloud entry.""" data: SwitchbotCloudData = hass.data[DOMAIN][config.entry_id] - async_add_entities( - _async_make_entity(data.api, device, coordinator) - for device, coordinator in data.devices.switches - ) + entities: list[SwitchBotCloudSwitch] = [] + for device, coordinator in data.devices.switches: + if device.device_type == "Relay Switch 2PM": + entities.append( + SwitchBotCloudRelaySwitch2PMSwitch(data.api, device, coordinator, "1") + ) + entities.append( + SwitchBotCloudRelaySwitch2PMSwitch(data.api, device, coordinator, "2") + ) + else: + entities.append(_async_make_entity(data.api, device, coordinator)) + + async_add_entities(entities) class SwitchBotCloudSwitch(SwitchBotCloudEntity, SwitchEntity): @@ -76,6 +87,54 @@ def _set_attributes(self) -> None: self._attr_is_on = self.coordinator.data.get("switchStatus") == 1 +class SwitchBotCloudRelaySwitch2PMSwitch(SwitchBotCloudSwitch): + """Representation of a SwitchBot relay switch.""" + + def __init__( + self, + api: SwitchBotAPI, + device: Device | Remote, + coordinator: SwitchBotCoordinator, + channel: str, + ) -> None: + """Init SwitchBotCloudRelaySwitch2PMSwitch.""" + super().__init__(api, device, coordinator) + self._channel = channel + self._device_id = device.device_id + self._attr_unique_id = f"{device.device_id}-{channel}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{device.device_name}-channel-{channel}")}, + manufacturer="SwitchBot", + model=device.device_type, + model_id="RelaySwitch2PM", + name=f"{device.device_name} Channel {channel}", + ) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the device on.""" + await self._api.send_command( + self._device_id, command=CommonCommands.ON, parameters=self._channel + ) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the device off.""" + await self._api.send_command( + self._device_id, command=CommonCommands.OFF, parameters=self._channel + ) + await asyncio.sleep(AFTER_COMMAND_REFRESH) + await self.coordinator.async_request_refresh() + + def _set_attributes(self) -> None: + """Set attributes from coordinator data.""" + if self.coordinator.data is None: + return + self._attr_is_on = ( + self.coordinator.data.get(f"switch{self._channel}Status") == 1 + ) + + @callback def _async_make_entity( api: SwitchBotAPI, device: Device | Remote, coordinator: SwitchBotCoordinator @@ -89,4 +148,5 @@ def _async_make_entity( return SwitchBotCloudPlugSwitch(api, device, coordinator) if "Bot" in device.device_type: return SwitchBotCloudSwitch(api, device, coordinator) + raise NotImplementedError(f"Unsupported device type: {device.device_type}") diff --git a/homeassistant/components/vegehub/const.py b/homeassistant/components/vegehub/const.py index 960ea4d3a911eb..ed9a115404ae76 100644 --- a/homeassistant/components/vegehub/const.py +++ b/homeassistant/components/vegehub/const.py @@ -4,6 +4,6 @@ DOMAIN = "vegehub" NAME = "VegeHub" -PLATFORMS = [Platform.SENSOR] +PLATFORMS = [Platform.SENSOR, Platform.SWITCH] MANUFACTURER = "vegetronix" MODEL = "VegeHub" diff --git a/homeassistant/components/vegehub/strings.json b/homeassistant/components/vegehub/strings.json index c35fe0d83c96dc..3566a9d6a8c40f 100644 --- a/homeassistant/components/vegehub/strings.json +++ b/homeassistant/components/vegehub/strings.json @@ -39,6 +39,11 @@ "battery_volts": { "name": "Battery voltage" } + }, + "switch": { + "switch": { + "name": "Actuator {index}" + } } } } diff --git a/homeassistant/components/vegehub/switch.py b/homeassistant/components/vegehub/switch.py new file mode 100644 index 00000000000000..aacb7330a552f3 --- /dev/null +++ b/homeassistant/components/vegehub/switch.py @@ -0,0 +1,80 @@ +"""Switch configuration for VegeHub integration.""" + +from typing import Any + +from homeassistant.components.switch import ( + SwitchDeviceClass, + SwitchEntity, + SwitchEntityDescription, +) +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .coordinator import VegeHubConfigEntry, VegeHubCoordinator +from .entity import VegeHubEntity + +SWITCH_TYPES: dict[str, SwitchEntityDescription] = { + "switch": SwitchEntityDescription( + key="switch", + translation_key="switch", + device_class=SwitchDeviceClass.SWITCH, + ) +} + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: VegeHubConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up VegeHub switches from a config entry.""" + coordinator = config_entry.runtime_data + + async_add_entities( + VegeHubSwitch( + index=i, + duration=600, # Default duration of 10 minutes + coordinator=coordinator, + description=SWITCH_TYPES["switch"], + ) + for i in range(coordinator.vegehub.num_actuators) + ) + + +class VegeHubSwitch(VegeHubEntity, SwitchEntity): + """Class for VegeHub Switches.""" + + _attr_device_class = SwitchDeviceClass.SWITCH + + def __init__( + self, + index: int, + duration: int, + coordinator: VegeHubCoordinator, + description: SwitchEntityDescription, + ) -> None: + """Initialize the switch.""" + super().__init__(coordinator) + self.entity_description = description + # Set unique ID for pulling data from the coordinator + self.data_key = f"actuator_{index}" + self._attr_unique_id = f"{self._mac_address}_{self.data_key}" + self._attr_translation_placeholders = {"index": str(index + 1)} + self._attr_available = False + self.index = index + self.duration = duration + + @property + def is_on(self) -> bool: + """Return True if the switch is on.""" + if self.coordinator.data is None or self._attr_unique_id is None: + return False + return self.coordinator.data.get(self.data_key, 0) > 0 + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + await self.coordinator.vegehub.set_actuator(1, self.index, self.duration) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + await self.coordinator.vegehub.set_actuator(0, self.index, self.duration) diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index 03b8f57c6eba1c..f9e50f9a26ce56 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -388,6 +388,7 @@ "met", "met_eireann", "meteo_france", + "meteo_lt", "meteoclimatic", "metoffice", "microbees", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 3289af99fe2d86..b0ef2400f04e8d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1674,6 +1674,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "eltako": { + "name": "Eltako", + "iot_standards": [ + "matter" + ] + }, "elv": { "name": "ELV PCA", "integration_type": "hub", @@ -3346,10 +3352,21 @@ "iot_class": "local_push" }, "konnected": { - "name": "Konnected.io (Legacy)", - "integration_type": "hub", - "config_flow": true, - "iot_class": "local_push" + "name": "Konnected", + "integrations": { + "konnected": { + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push", + "name": "Konnected.io (Legacy)" + }, + "konnected_esphome": { + "integration_type": "virtual", + "config_flow": false, + "supported_by": "esphome", + "name": "Konnected" + } + } }, "kostal_plenticore": { "name": "Kostal Plenticore Solar Inverter", @@ -3476,6 +3493,12 @@ "config_flow": true, "iot_class": "cloud_push" }, + "level": { + "name": "Level", + "iot_standards": [ + "matter" + ] + }, "leviton": { "name": "Leviton", "iot_standards": [ @@ -3897,6 +3920,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "meteo_lt": { + "name": "Meteo.lt", + "integration_type": "service", + "config_flow": true, + "iot_class": "cloud_polling" + }, "meteoalarm": { "name": "MeteoAlarm", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index 334e3693f682ac..d66bbdfb116f06 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -238,7 +238,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2025.9.1 +aioecowitt==2025.9.2 # homeassistant.components.co2signal aioelectricitymaps==1.1.1 @@ -1436,6 +1436,9 @@ melnor-bluetooth==0.0.25 # homeassistant.components.message_bird messagebird==1.2.0 +# homeassistant.components.meteo_lt +meteo-lt-pkg==0.2.4 + # homeassistant.components.meteoalarm meteoalertapi==0.3.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 351d3419f341d4..16397e62653837 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -226,7 +226,7 @@ aioeafm==0.1.2 aioeagle==1.1.0 # homeassistant.components.ecowitt -aioecowitt==2025.9.1 +aioecowitt==2025.9.2 # homeassistant.components.co2signal aioelectricitymaps==1.1.1 @@ -1231,6 +1231,9 @@ medcom-ble==0.1.1 # homeassistant.components.melnor melnor-bluetooth==0.0.25 +# homeassistant.components.meteo_lt +meteo-lt-pkg==0.2.4 + # homeassistant.components.meteo_france meteofrance-api==1.4.0 diff --git a/tests/components/aladdin_connect/test_config_flow.py b/tests/components/aladdin_connect/test_config_flow.py index d69c588a649099..ee555cf2ebb8c9 100644 --- a/tests/components/aladdin_connect/test_config_flow.py +++ b/tests/components/aladdin_connect/test_config_flow.py @@ -10,7 +10,7 @@ OAUTH2_AUTHORIZE, OAUTH2_TOKEN, ) -from homeassistant.config_entries import SOURCE_DHCP +from homeassistant.config_entries import SOURCE_DHCP, SOURCE_USER from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow @@ -23,6 +23,12 @@ from tests.typing import ClientSessionGenerator +@pytest.fixture +def use_cloud(hass: HomeAssistant) -> None: + """Set up the cloud component.""" + hass.config.components.add("cloud") + + @pytest.fixture async def access_token(hass: HomeAssistant) -> str: """Return a valid access token with sub field for unique ID.""" @@ -37,7 +43,7 @@ async def access_token(hass: HomeAssistant) -> str: ) -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_full_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -97,7 +103,7 @@ async def test_full_flow( assert result["result"].unique_id == USER_ID -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_full_dhcp_flow( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -170,7 +176,7 @@ async def test_full_dhcp_flow( assert result["result"].unique_id == USER_ID -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_duplicate_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -221,7 +227,7 @@ async def test_duplicate_entry( assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_duplicate_dhcp_entry( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -243,7 +249,7 @@ async def test_duplicate_dhcp_entry( assert result["reason"] == "already_configured" -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_flow_reauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -306,7 +312,7 @@ async def test_flow_reauth( assert len(hass.config_entries.async_entries(DOMAIN)) == 1 -@pytest.mark.usefixtures("current_request_with_host") +@pytest.mark.usefixtures("current_request_with_host", "use_cloud") async def test_flow_wrong_account_reauth( hass: HomeAssistant, hass_client_no_auth: ClientSessionGenerator, @@ -370,3 +376,39 @@ async def test_flow_wrong_account_reauth( # Should abort with wrong account assert result["type"] == "abort" assert result["reason"] == "wrong_account" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, +) -> None: + """Check we abort when cloud is not enabled.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_reauthentication_no_cloud( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + mock_config_entry: MockConfigEntry, +) -> None: + """Test Aladdin Connect reauthentication without cloud.""" + mock_config_entry.add_to_hass(hass) + + result = await mock_config_entry.start_reauth_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cloud_not_enabled" diff --git a/tests/components/habitica/fixtures/party_members_2.json b/tests/components/habitica/fixtures/party_members_2.json new file mode 100644 index 00000000000000..249a6d6bc87fb2 --- /dev/null +++ b/tests/components/habitica/fixtures/party_members_2.json @@ -0,0 +1,238 @@ +{ + "success": true, + "data": [ + { + "_id": "a380546a-94be-4b8e-8a0b-23e0d5c03303", + "auth": { + "local": { + "username": "test-username" + }, + "timestamps": { + "created": "2024-10-19T18:43:39.782Z", + "loggedin": "2024-10-31T16:13:35.048Z", + "updated": "2024-10-31T16:15:56.552Z" + } + }, + "achievements": { + "ultimateGearSets": { + "healer": false, + "wizard": false, + "rogue": false, + "warrior": false + }, + "streak": 0, + "challenges": [], + "perfect": 1, + "quests": {}, + "purchasedEquipment": true, + "completedTask": true, + "partyUp": true + }, + "backer": {}, + "contributor": {}, + "flags": { + "verifiedUsername": true, + "classSelected": true + }, + "items": { + "gear": { + "owned": { + "headAccessory_special_blackHeadband": true, + "headAccessory_special_blueHeadband": true, + "headAccessory_special_greenHeadband": true, + "headAccessory_special_pinkHeadband": true, + "headAccessory_special_redHeadband": true, + "headAccessory_special_whiteHeadband": true, + "headAccessory_special_yellowHeadband": true, + "eyewear_special_blackTopFrame": true, + "eyewear_special_blueTopFrame": true, + "eyewear_special_greenTopFrame": true, + "eyewear_special_pinkTopFrame": true, + "eyewear_special_redTopFrame": true, + "eyewear_special_whiteTopFrame": true, + "eyewear_special_yellowTopFrame": true, + "eyewear_special_blackHalfMoon": true, + "eyewear_special_blueHalfMoon": true, + "eyewear_special_greenHalfMoon": true, + "eyewear_special_pinkHalfMoon": true, + "eyewear_special_redHalfMoon": true, + "eyewear_special_whiteHalfMoon": true, + "eyewear_special_yellowHalfMoon": true, + "armor_special_bardRobes": true, + "weapon_special_fall2024Warrior": true, + "shield_special_fall2024Warrior": true, + "head_special_fall2024Warrior": true, + "armor_special_fall2024Warrior": true, + "back_mystery_201402": true, + "body_mystery_202003": true, + "head_special_bardHat": true, + "weapon_wizard_0": true + }, + "equipped": { + "weapon": "weapon_special_fall2024Warrior", + "armor": "armor_special_fall2024Warrior", + "head": "head_special_fall2024Warrior", + "shield": "shield_special_fall2024Warrior", + "back": "back_mystery_201402", + "headAccessory": "headAccessory_special_pinkHeadband", + "eyewear": "eyewear_special_pinkHalfMoon", + "body": "body_mystery_202003" + }, + "costume": { + "armor": "armor_base_0", + "head": "head_base_0", + "shield": "shield_base_0" + } + }, + "special": { + "snowball": 99, + "spookySparkles": 99, + "shinySeed": 99, + "seafoam": 99, + "valentine": 0, + "valentineReceived": [], + "nye": 0, + "nyeReceived": [], + "greeting": 0, + "greetingReceived": [], + "thankyou": 0, + "thankyouReceived": [], + "birthday": 0, + "birthdayReceived": [], + "congrats": 0, + "congratsReceived": [], + "getwell": 0, + "getwellReceived": [], + "goodluck": 0, + "goodluckReceived": [] + }, + "pets": { + "Rat-Shade": 1, + "Gryphatrice-Jubilant": 1 + }, + "currentPet": "Gryphatrice-Jubilant", + "eggs": { + "Cactus": 1, + "Fox": 2, + "Wolf": 1 + }, + "hatchingPotions": { + "CottonCandyBlue": 1, + "RoyalPurple": 1 + }, + "food": { + "Meat": 2, + "Chocolate": 1, + "CottonCandyPink": 1, + "Candy_Zombie": 1 + }, + "mounts": { + "Velociraptor-Base": true, + "Gryphon-Gryphatrice": true + }, + "currentMount": "Gryphon-Gryphatrice", + "quests": { + "dustbunnies": 1, + "vice1": 1, + "atom1": 1, + "moonstone1": 1, + "goldenknight1": 1, + "basilist": 1 + }, + "lastDrop": { + "date": "2024-10-31T16:13:34.952Z", + "count": 0 + } + }, + "party": { + "quest": { + "progress": { + "up": 0, + "down": 0, + "collectedItems": 0, + "collect": {} + }, + "RSVPNeeded": false, + "key": "dustbunnies" + }, + "order": "level", + "orderAscending": "ascending", + "_id": "94cd398c-2240-4320-956e-6d345cf2c0de" + }, + "preferences": { + "size": "slim", + "hair": { + "color": "red", + "base": 3, + "bangs": 1, + "beard": 0, + "mustache": 0, + "flower": 1 + }, + "skin": "915533", + "shirt": "blue", + "chair": "handleless_pink", + "costume": false, + "sleep": false, + "disableClasses": false, + "tasks": { + "groupByChallenge": false, + "confirmScoreNotes": false, + "mirrorGroupTasks": [], + "activeFilter": { + "habit": "all", + "daily": "all", + "todo": "remaining", + "reward": "all" + } + }, + "background": "violet" + }, + "profile": { + "name": "test-user" + }, + "stats": { + "hp": 50, + "mp": 150.8, + "exp": 127, + "gp": 19.08650199252128, + "lvl": 99, + "class": "wizard", + "points": 0, + "str": 0, + "con": 0, + "int": 0, + "per": 0, + "buffs": { + "str": 50, + "int": 50, + "per": 50, + "con": 50, + "stealth": 0, + "streaks": false, + "seafoam": false, + "shinySeed": false, + "snowball": false, + "spookySparkles": false + }, + "training": { + "int": 0, + "per": 0, + "str": 0, + "con": 0 + }, + "toNextLevel": 3580, + "maxHealth": 50, + "maxMP": 228 + }, + "inbox": { + "optOut": false + }, + "loginIncentives": 6, + "id": "a380546a-94be-4b8e-8a0b-23e0d5c03303" + } + ], + "notifications": [], + "userV": 96, + "appVersion": "5.29.0" +} diff --git a/tests/components/habitica/snapshots/test_notify.ambr b/tests/components/habitica/snapshots/test_notify.ambr new file mode 100644 index 00000000000000..248f6e292d6f5b --- /dev/null +++ b/tests/components/habitica/snapshots/test_notify.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_notify_platform[notify.test_user_party_chat-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.test_user_party_chat', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Party chat', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_party_chat', + 'unit_of_measurement': None, + }) +# --- +# name: test_notify_platform[notify.test_user_party_chat-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'test-user Party chat', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.test_user_party_chat', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_notify_platform[notify.test_user_private_message_test_partymember_displayname-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'notify', + 'entity_category': None, + 'entity_id': 'notify.test_user_private_message_test_partymember_displayname', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Private message: test-partymember-displayname', + 'platform': 'habitica', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': , + 'unique_id': 'a380546a-94be-4b8e-8a0b-23e0d5c03303_ffce870c-3ff3-4fa4-bad1-87612e52b8e7_private_message', + 'unit_of_measurement': None, + }) +# --- +# name: test_notify_platform[notify.test_user_private_message_test_partymember_displayname-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'test-user Private message: test-partymember-displayname', + 'supported_features': , + }), + 'context': , + 'entity_id': 'notify.test_user_private_message_test_partymember_displayname', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/habitica/test_init.py b/tests/components/habitica/test_init.py index 92be6cbe88112f..469197b54b15c7 100644 --- a/tests/components/habitica/test_init.py +++ b/tests/components/habitica/test_init.py @@ -139,7 +139,7 @@ async def test_remove_party_and_reload( freezer: FrozenDateTimeFactory, device_registry: dr.DeviceRegistry, ) -> None: - """Test we leave the party and device is removed.""" + """Test we leave the party and device/notifiers are removed.""" group_id = "1e87097c-4c03-4f8c-a475-67cc7da7f409" config_entry.add_to_hass(hass) await hass.config_entries.async_setup(config_entry.entry_id) @@ -154,6 +154,11 @@ async def test_remove_party_and_reload( is not None ) + assert hass.states.get("notify.test_user_party_chat") + assert hass.states.get( + "notify.test_user_private_message_test_partymember_displayname" + ) + habitica.get_user.return_value = HabiticaUserResponse.from_json( await async_load_fixture(hass, "user_no_party.json", DOMAIN) ) @@ -168,3 +173,9 @@ async def test_remove_party_and_reload( ) is None ) + + assert hass.states.get("notify.test_user_party_chat") is None + assert ( + hass.states.get("notify.test_user_private_message_test_partymember_displayname") + is None + ) diff --git a/tests/components/habitica/test_notify.py b/tests/components/habitica/test_notify.py new file mode 100644 index 00000000000000..6f2988a3fcccf7 --- /dev/null +++ b/tests/components/habitica/test_notify.py @@ -0,0 +1,191 @@ +"""Tests for the Habitica notify platform.""" + +from collections.abc import AsyncGenerator +from datetime import timedelta +from typing import Any +from unittest.mock import AsyncMock, patch +from uuid import UUID + +from aiohttp import ClientError +from freezegun.api import FrozenDateTimeFactory, freeze_time +from habiticalib import HabiticaGroupMembersResponse +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.habitica.const import DOMAIN +from homeassistant.components.notify import ( + ATTR_MESSAGE, + DOMAIN as NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, +) +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import entity_registry as er + +from .conftest import ( + ERROR_BAD_REQUEST, + ERROR_NOT_AUTHORIZED, + ERROR_NOT_FOUND, + ERROR_TOO_MANY_REQUESTS, +) + +from tests.common import ( + MockConfigEntry, + async_fire_time_changed, + async_load_fixture, + snapshot_platform, +) + + +@pytest.fixture(autouse=True) +async def notify_only() -> AsyncGenerator[None]: + """Enable only the notify platform.""" + with patch( + "homeassistant.components.habitica.PLATFORMS", + [Platform.NOTIFY], + ): + yield + + +@pytest.mark.usefixtures("habitica") +async def test_notify_platform( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the notify platform.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +@pytest.mark.parametrize( + ("entity_id", "call_method", "call_args"), + [ + ( + "notify.test_user_party_chat", + "send_group_message", + {"group_id": UUID("1e87097c-4c03-4f8c-a475-67cc7da7f409")}, + ), + ( + "notify.test_user_private_message_test_partymember_displayname", + "send_private_message", + {"to_user_id": UUID("ffce870c-3ff3-4fa4-bad1-87612e52b8e7")}, + ), + ], +) +@freeze_time("2025-08-13T00:00:00+00:00") +async def test_send_message( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + entity_id: str, + call_method: str, + call_args: dict[str, Any], +) -> None: + """Test send message.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + state = hass.states.get(entity_id) + assert state + assert state.state == STATE_UNKNOWN + + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: entity_id, + ATTR_MESSAGE: "Greetings, fellow adventurer", + }, + blocking=True, + ) + + state = hass.states.get(entity_id) + assert state + assert state.state == "2025-08-13T00:00:00+00:00" + getattr(habitica, call_method).assert_called_once_with( + message="Greetings, fellow adventurer", **call_args + ) + + +@pytest.mark.parametrize( + "exception", + [ + ERROR_BAD_REQUEST, + ERROR_NOT_AUTHORIZED, + ERROR_NOT_FOUND, + ERROR_TOO_MANY_REQUESTS, + ClientError, + ], +) +async def test_send_message_exceptions( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + exception: Exception, +) -> None: + """Test send message exceptions.""" + + habitica.send_group_message.side_effect = exception + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + NOTIFY_DOMAIN, + SERVICE_SEND_MESSAGE, + { + ATTR_ENTITY_ID: "notify.test_user_party_chat", + ATTR_MESSAGE: "Greetings, fellow adventurer", + }, + blocking=True, + ) + + +async def test_remove_stale_entities( + hass: HomeAssistant, + config_entry: MockConfigEntry, + habitica: AsyncMock, + freezer: FrozenDateTimeFactory, +) -> None: + """Test removing stale private message entities.""" + + config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.LOADED + + assert hass.states.get( + "notify.test_user_private_message_test_partymember_displayname" + ) + + habitica.get_group_members.return_value = HabiticaGroupMembersResponse.from_json( + await async_load_fixture(hass, "party_members_2.json", DOMAIN) + ) + + freezer.tick(timedelta(minutes=15)) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert ( + hass.states.get("notify.test_user_private_message_test_partymember_displayname") + is None + ) diff --git a/tests/components/homeassistant_connect_zbt2/test_config_flow.py b/tests/components/homeassistant_connect_zbt2/test_config_flow.py index ff26c246a40f81..54f70c57c4900a 100644 --- a/tests/components/homeassistant_connect_zbt2/test_config_flow.py +++ b/tests/components/homeassistant_connect_zbt2/test_config_flow.py @@ -72,6 +72,13 @@ async def mock_install_firmware_step( step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -80,16 +87,6 @@ async def mock_install_firmware_step( autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=usb_data.device, - firmware_type=fw_type, - firmware_version=fw_version, - owners=[], - source="probe", - ), - ), ): pick_result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -157,6 +154,13 @@ async def mock_install_firmware_step( step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -165,16 +169,6 @@ async def mock_install_firmware_step( autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=usb_data.device, - firmware_type=fw_type, - firmware_version=fw_version, - owners=[], - source="probe", - ), - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -258,6 +252,13 @@ async def mock_install_firmware_step( step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version="7.4.4.0 build 0", + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -270,16 +271,6 @@ async def mock_install_firmware_step( autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=usb_data.device, - firmware_type=ApplicationType.EZSP, - firmware_version="7.4.4.0 build 0", - owners=[], - source="probe", - ), - ), ): pick_result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 217c331257e3cd..b8fd9e5cee8871 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -36,171 +36,6 @@ async def fixture_mock_supervisor_client(supervisor_client: AsyncMock): """Mock supervisor client in tests.""" -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -@pytest.mark.usefixtures("addon_store_info") -async def test_config_flow_cannot_probe_firmware_zigbee(hass: HomeAssistant) -> None: - """Test failure case when firmware cannot be probed for zigbee.""" - - with mock_firmware_info( - probe_app_type=None, - ): - # Start the flow - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "pick_firmware" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "zigbee_installation_type" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": "zigbee_intent_recommended"}, - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "unsupported_firmware" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -async def test_cannot_probe_after_install_zigbee(hass: HomeAssistant) -> None: - """Test unsupported firmware after firmware install for Zigbee.""" - init_result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - assert init_result["type"] is FlowResultType.MENU - assert init_result["step_id"] == "pick_firmware" - - with mock_firmware_info( - probe_app_type=ApplicationType.SPINEL, - flash_app_type=ApplicationType.EZSP, - ): - # Pick the menu option: we are flashing the firmware - pick_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, - ) - - assert pick_result["type"] is FlowResultType.MENU - assert pick_result["step_id"] == "zigbee_installation_type" - - pick_result = await hass.config_entries.flow.async_configure( - pick_result["flow_id"], - user_input={"next_step_id": "zigbee_intent_recommended"}, - ) - - assert pick_result["type"] is FlowResultType.SHOW_PROGRESS - assert pick_result["progress_action"] == "install_firmware" - assert pick_result["step_id"] == "install_zigbee_firmware" - - with mock_firmware_info( - probe_app_type=None, - flash_app_type=ApplicationType.EZSP, - ): - create_result = await consume_progress_flow( - hass, - flow_id=pick_result["flow_id"], - valid_step_ids=("install_zigbee_firmware",), - ) - - assert create_result["type"] is FlowResultType.ABORT - assert create_result["reason"] == "unsupported_firmware" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -@pytest.mark.usefixtures("addon_store_info") -async def test_config_flow_cannot_probe_firmware_thread(hass: HomeAssistant) -> None: - """Test failure case when firmware cannot be probed for thread.""" - - with mock_firmware_info( - probe_app_type=None, - ): - # Start the flow - result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "pick_firmware" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, - ) - - assert result["type"] == FlowResultType.ABORT - assert result["reason"] == "unsupported_firmware" - - -@pytest.mark.parametrize( - "ignore_translations_for_mock_domains", - ["test_firmware_domain"], -) -@pytest.mark.usefixtures("addon_installed") -async def test_cannot_probe_after_install_thread(hass: HomeAssistant) -> None: - """Test unsupported firmware after firmware install for thread.""" - init_result = await hass.config_entries.flow.async_init( - TEST_DOMAIN, context={"source": "hardware"} - ) - - assert init_result["type"] is FlowResultType.MENU - assert init_result["step_id"] == "pick_firmware" - - with mock_firmware_info( - probe_app_type=ApplicationType.EZSP, - flash_app_type=ApplicationType.SPINEL, - ): - # Pick the menu option - pick_result = await hass.config_entries.flow.async_configure( - init_result["flow_id"], - user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, - ) - - assert pick_result["type"] is FlowResultType.SHOW_PROGRESS - assert pick_result["progress_action"] == "install_firmware" - assert pick_result["step_id"] == "install_thread_firmware" - description_placeholders = pick_result["description_placeholders"] - assert description_placeholders is not None - assert description_placeholders["firmware_type"] == "ezsp" - assert description_placeholders["model"] == TEST_HARDWARE_NAME - - with mock_firmware_info( - probe_app_type=None, - flash_app_type=ApplicationType.SPINEL, - ): - # Progress the flow, it is now installing firmware - result = await consume_progress_flow( - hass, - flow_id=pick_result["flow_id"], - valid_step_ids=( - "pick_firmware_thread", - "install_otbr_addon", - "install_thread_firmware", - "start_otbr_addon", - ), - ) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "unsupported_firmware" - - @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], @@ -217,11 +52,21 @@ async def test_config_flow_thread_not_hassio(hass: HomeAssistant) -> None: with mock_firmware_info( is_hassio=False, probe_app_type=ApplicationType.EZSP, + flash_app_type=ApplicationType.SPINEL, ): result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_thread_firmware" + + result = await consume_progress_flow( + hass, + flow_id=result["flow_id"], + valid_step_ids=("install_thread_firmware",), + ) + assert result["type"] is FlowResultType.ABORT assert result["reason"] == "not_hassio_thread" @@ -245,6 +90,7 @@ async def test_config_flow_thread_addon_info_fails( with mock_firmware_info( probe_app_type=ApplicationType.EZSP, + flash_app_type=ApplicationType.SPINEL, ): addon_store_info.side_effect = AddonError() result = await hass.config_entries.flow.async_configure( @@ -252,6 +98,15 @@ async def test_config_flow_thread_addon_info_fails( user_input={"next_step_id": STEP_PICK_FIRMWARE_THREAD}, ) + assert result["type"] is FlowResultType.SHOW_PROGRESS + assert result["step_id"] == "install_thread_firmware" + + result = await consume_progress_flow( + hass, + flow_id=result["flow_id"], + valid_step_ids=("install_thread_firmware",), + ) + # Cannot get addon info assert result["type"] == FlowResultType.ABORT assert result["reason"] == "addon_info_failed" diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index d977a2ba8a14a3..01478900c60fd2 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -91,6 +91,13 @@ async def mock_install_firmware_step( step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -99,16 +106,6 @@ async def mock_install_firmware_step( autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=usb_data.device, - firmware_type=fw_type, - firmware_version=fw_version, - owners=[], - source="probe", - ), - ), ): pick_result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -190,6 +187,13 @@ async def mock_install_firmware_step( step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -198,16 +202,6 @@ async def mock_install_firmware_step( autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=usb_data.device, - firmware_type=fw_type, - firmware_version=fw_version, - owners=[], - source="probe", - ), - ), ): result = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -293,6 +287,13 @@ async def mock_install_firmware_step( step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=usb_data.device, + firmware_type=expected_installed_firmware_type, + firmware_version="7.4.4.0 build 0", + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -305,16 +306,6 @@ async def mock_install_firmware_step( autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=usb_data.device, - firmware_type=ApplicationType.EZSP, - firmware_version="7.4.4.0 build 0", - owners=[], - source="probe", - ), - ), ): pick_result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index df4bee29eab090..3a85ed017cb446 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -362,6 +362,13 @@ async def mock_install_firmware_step( step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -374,16 +381,6 @@ async def mock_install_firmware_step( autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=RADIO_DEVICE, - firmware_type=fw_type, - firmware_version=fw_version, - owners=[], - source="probe", - ), - ), ): pick_result = await hass.config_entries.options.async_configure( result["flow_id"], @@ -453,6 +450,13 @@ async def mock_install_firmware_step( step_id: str, next_step_id: str, ) -> ConfigFlowResult: + self._probed_firmware_info = FirmwareInfo( + device=RADIO_DEVICE, + firmware_type=expected_installed_firmware_type, + firmware_version=fw_version, + owners=[], + source="probe", + ) return await getattr(self, f"async_step_{next_step_id}")() with ( @@ -465,16 +469,6 @@ async def mock_install_firmware_step( autospec=True, side_effect=mock_install_firmware_step, ), - patch( - "homeassistant.components.homeassistant_hardware.firmware_config_flow.probe_silabs_firmware_info", - return_value=FirmwareInfo( - device=RADIO_DEVICE, - firmware_type=fw_type, - firmware_version=fw_version, - owners=[], - source="probe", - ), - ), ): result = await hass.config_entries.options.async_configure( result["flow_id"], diff --git a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr index 0a392e101c5153..381002b1f8bd23 100644 --- a/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr +++ b/tests/components/jewish_calendar/snapshots/test_diagnostics.ambr @@ -30,7 +30,7 @@ 'zmanim': dict({ 'candle_lighting_offset': 40, 'date': dict({ - '__type': "", + '__type': "", 'isoformat': '2025-05-19', }), 'havdalah_offset': 0, @@ -86,7 +86,7 @@ 'zmanim': dict({ 'candle_lighting_offset': 18, 'date': dict({ - '__type': "", + '__type': "", 'isoformat': '2025-05-19', }), 'havdalah_offset': 0, @@ -142,7 +142,7 @@ 'zmanim': dict({ 'candle_lighting_offset': 18, 'date': dict({ - '__type': "", + '__type': "", 'isoformat': '2025-05-19', }), 'havdalah_offset': 0, diff --git a/tests/components/litterrobot/test_update.py b/tests/components/litterrobot/test_update.py index b1b092e1f023a8..f7d7492dec80e2 100644 --- a/tests/components/litterrobot/test_update.py +++ b/tests/components/litterrobot/test_update.py @@ -5,9 +5,11 @@ from pylitterbot import LitterRobot4 import pytest +from homeassistant.components.litterrobot.update import RELEASE_URL from homeassistant.components.update import ( ATTR_INSTALLED_VERSION, ATTR_LATEST_VERSION, + ATTR_RELEASE_URL, DOMAIN as PLATFORM_DOMAIN, SERVICE_INSTALL, UpdateDeviceClass, @@ -47,6 +49,7 @@ async def test_robot_with_no_update( assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE assert state.attributes[ATTR_INSTALLED_VERSION] == OLD_FIRMWARE assert state.attributes[ATTR_LATEST_VERSION] == OLD_FIRMWARE + assert state.attributes[ATTR_RELEASE_URL] == RELEASE_URL assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() @@ -68,6 +71,7 @@ async def test_robot_with_update( assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE assert state.attributes[ATTR_INSTALLED_VERSION] == OLD_FIRMWARE assert state.attributes[ATTR_LATEST_VERSION] == NEW_FIRMWARE + assert state.attributes[ATTR_RELEASE_URL] == RELEASE_URL robot.update_firmware = AsyncMock(return_value=False) @@ -106,6 +110,7 @@ async def test_robot_with_update_already_in_progress( assert state.attributes[ATTR_DEVICE_CLASS] == UpdateDeviceClass.FIRMWARE assert state.attributes[ATTR_INSTALLED_VERSION] == OLD_FIRMWARE assert state.attributes[ATTR_LATEST_VERSION] is None + assert state.attributes[ATTR_RELEASE_URL] == RELEASE_URL assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() diff --git a/tests/components/mealie/snapshots/test_services.ambr b/tests/components/mealie/snapshots/test_services.ambr index b8afee7c9d5289..7ec3fc6139eaf5 100644 --- a/tests/components/mealie/snapshots/test_services.ambr +++ b/tests/components/mealie/snapshots/test_services.ambr @@ -1739,7 +1739,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 22), + 'mealplan_date': HAFakeDate(2024, 1, 22), 'mealplan_id': 230, 'recipe': dict({ 'description': "Een traybake is eigenlijk altijd een goed idee. Deze zoete aardappel curry traybake dus ook. Waarom? Omdat je alleen maar wat groenten - en in dit geval kip - op een bakplaat (traybake dus) legt, hier wat kruiden aan toevoegt en deze in de oven schuift. Ideaal dus als je geen zin hebt om lang in de keuken te staan. Maar gewoon lekker op de bank wil ploffen om te wachten tot de oven klaar is. Joe! That\\'s what we like. Deze zoete aardappel curry traybake bevat behalve zoete aardappel en curry ook kikkererwten, kippendijfilet en bloemkoolroosjes. Je gebruikt yoghurt en limoen als een soort dressing. En je serveert deze heerlijke traybake met naanbrood. Je kunt natuurljk ook voor deze traybake met chipolataworstjes gaan. Wil je graag meer ovengerechten? Dan moet je eigenlijk even kijken naar onze Ovenbijbel. Onmisbaar in je keuken! We willen je deze zoete aardappelstamppot met prei ook niet onthouden. Megalekker bordje comfortfood als je \\'t ons vraagt.", @@ -1764,7 +1764,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 229, 'recipe': dict({ 'description': 'The BEST Roast Chicken recipe is simple, budget friendly, and gives you a tender, mouth-watering chicken full of flavor! Served with roasted vegetables, this recipe is simple enough for any cook!', @@ -1789,7 +1789,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 226, 'recipe': dict({ 'description': 'Te explicamos paso a paso, de manera sencilla, la elaboración de la receta de pollo al curry con leche de coco en 10 minutos. Ingredientes, tiempo de...', @@ -1814,7 +1814,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 224, 'recipe': dict({ 'description': 'bourguignon, oignon, carotte, bouquet garni, vin rouge, beurre, sel, poivre', @@ -1839,7 +1839,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 222, 'recipe': dict({ 'description': 'Εύκολη μακαρονάδα με κεφτεδάκια στον φούρνο από τον Άκη Πετρετζίκη. Φτιάξτε την πιο εύκολη μακαρονάδα με κεφτεδάκια σε μόνο ένα σκεύος.', @@ -1864,7 +1864,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 221, 'recipe': dict({ 'description': 'Delicious Greek turkey meatballs with lemon orzo, tender veggies, and a creamy feta yogurt sauce. These healthy baked Greek turkey meatballs are filled with tons of wonderful herbs and make the perfect protein-packed weeknight meal!', @@ -1889,7 +1889,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 220, 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', @@ -1914,7 +1914,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 219, 'recipe': dict({ 'description': 'This is a modified Pampered Chef recipe. You can use a trifle bowl or large glass punch/salad bowl to show it off. It is really easy to make and I never have any leftovers. Cook time includes chill time.', @@ -1939,7 +1939,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 22), + 'mealplan_date': HAFakeDate(2024, 1, 22), 'mealplan_id': 217, 'recipe': dict({ 'description': 'Cheeseburger Sliders are juicy, cheesy and beefy - everything we love about classic burgers! These sliders are quick and easy plus they are make-ahead and reheat really well.', @@ -1964,7 +1964,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 22), + 'mealplan_date': HAFakeDate(2024, 1, 22), 'mealplan_id': 216, 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', @@ -1989,7 +1989,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 212, 'recipe': dict({ 'description': 'This All-American beef stew recipe includes tender beef coated in a rich, intense sauce and vegetables that bring complementary texture and flavor.', @@ -2014,7 +2014,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 22), + 'mealplan_date': HAFakeDate(2024, 1, 22), 'mealplan_id': 211, 'recipe': dict({ 'description': 'Einfacher Nudelauflauf mit Brokkoli, Sahnesauce und extra Käse. Dieses vegetarische 5 Zutaten Rezept ist super schnell gemacht und SO gut!', @@ -2039,7 +2039,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 23), + 'mealplan_date': HAFakeDate(2024, 1, 23), 'mealplan_id': 196, 'recipe': dict({ 'description': 'Simple to prepare and ready in 25 minutes, this vegetarian miso noodle recipe can be eaten on its own or served as a side.', @@ -2064,7 +2064,7 @@ 'entry_type': , 'group_id': '0bf60b2e-ca89-42a9-94d4-8f67ca72b157', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 22), + 'mealplan_date': HAFakeDate(2024, 1, 22), 'mealplan_id': 195, 'recipe': dict({ 'description': 'Avis aux nostalgiques des années 1980, la mousse de saumon est de retour dans une présentation adaptée au goût du jour. On utilise une technique sans faille : un saumon frais cuit au micro-ondes et mélangé au robot avec du fromage à la crème et de la crème sure. On obtient ainsi une texture onctueuse à tartiner, qui n’a rien à envier aux préparations gélatineuses d’antan !', @@ -2089,7 +2089,7 @@ 'entry_type': , 'group_id': '3931df86-0679-4579-8c63-4bedc9ca9a85', 'household_id': None, - 'mealplan_date': FakeDate(2024, 1, 21), + 'mealplan_date': HAFakeDate(2024, 1, 21), 'mealplan_id': 1, 'recipe': None, 'title': 'Aquavite', diff --git a/tests/components/media_player/test_intent.py b/tests/components/media_player/test_intent.py index 2b5853198261ba..3fb12b1a90dd9b 100644 --- a/tests/components/media_player/test_intent.py +++ b/tests/components/media_player/test_intent.py @@ -13,6 +13,7 @@ SERVICE_MEDIA_PREVIOUS_TRACK, SERVICE_PLAY_MEDIA, SERVICE_SEARCH_MEDIA, + SERVICE_VOLUME_MUTE, SERVICE_VOLUME_SET, BrowseMedia, MediaClass, @@ -265,6 +266,88 @@ async def test_volume_media_player_intent(hass: HomeAssistant) -> None: ) +async def test_media_player_mute_intent(hass: HomeAssistant) -> None: + """Test HassMediaPlayerMute intent for media players.""" + await media_player_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_media_player" + attributes = {ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_MUTE} + + hass.states.async_set(entity_id, STATE_PLAYING, attributes=attributes) + calls = async_mock_service(hass, DOMAIN, SERVICE_VOLUME_MUTE) + + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_PLAYER_MUTE, + {}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_VOLUME_MUTE + assert call.data == {"entity_id": entity_id, "is_volume_muted": True} + + # Test feature not supported + hass.states.async_set( + entity_id, + STATE_PLAYING, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)}, + ) + + with pytest.raises(intent.MatchFailedError): + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_PLAYER_MUTE, + {}, + ) + + +async def test_media_player_unmute_intent(hass: HomeAssistant) -> None: + """Test HassMediaPlayerMute intent for media players.""" + await media_player_intent.async_setup_intents(hass) + + entity_id = f"{DOMAIN}.test_media_player" + attributes = {ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature.VOLUME_MUTE} + + hass.states.async_set(entity_id, STATE_PLAYING, attributes=attributes) + calls = async_mock_service(hass, DOMAIN, SERVICE_VOLUME_MUTE) + + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_PLAYER_UNMUTE, + {}, + ) + await hass.async_block_till_done() + + assert response.response_type == intent.IntentResponseType.ACTION_DONE + assert len(calls) == 1 + call = calls[0] + assert call.domain == DOMAIN + assert call.service == SERVICE_VOLUME_MUTE + assert call.data == {"entity_id": entity_id, "is_volume_muted": False} + + # Test feature not supported + hass.states.async_set( + entity_id, + STATE_PLAYING, + attributes={ATTR_SUPPORTED_FEATURES: MediaPlayerEntityFeature(0)}, + ) + + with pytest.raises(intent.MatchFailedError): + response = await intent.async_handle( + hass, + "test", + media_player_intent.INTENT_PLAYER_UNMUTE, + {}, + ) + + async def test_multiple_media_players( hass: HomeAssistant, area_registry: ar.AreaRegistry, diff --git a/tests/components/meteo_lt/__init__.py b/tests/components/meteo_lt/__init__.py new file mode 100644 index 00000000000000..798b9bd2a790a1 --- /dev/null +++ b/tests/components/meteo_lt/__init__.py @@ -0,0 +1 @@ +"""Tests for Meteo.lt integration.""" diff --git a/tests/components/meteo_lt/conftest.py b/tests/components/meteo_lt/conftest.py new file mode 100644 index 00000000000000..97bfc5c044ca2e --- /dev/null +++ b/tests/components/meteo_lt/conftest.py @@ -0,0 +1,68 @@ +"""Fixtures for Meteo.lt integration tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from meteo_lt import Forecast, MeteoLtAPI, Place +import pytest + +from homeassistant.components.meteo_lt.const import CONF_PLACE_CODE, DOMAIN + +from tests.common import ( + MockConfigEntry, + load_json_array_fixture, + load_json_object_fixture, +) + + +@pytest.fixture(autouse=True) +def mock_meteo_lt_api() -> Generator[AsyncMock]: + """Mock MeteoLtAPI with fixture data.""" + with ( + patch( + "homeassistant.components.meteo_lt.coordinator.MeteoLtAPI", + autospec=True, + ) as mock_api_class, + patch( + "homeassistant.components.meteo_lt.config_flow.MeteoLtAPI", + new=mock_api_class, + ), + ): + mock_api = AsyncMock(spec=MeteoLtAPI) + mock_api_class.return_value = mock_api + + places_data = load_json_array_fixture("places.json", DOMAIN) + forecast_data = load_json_object_fixture("forecast.json", DOMAIN) + + mock_places = [Place.from_dict(place_data) for place_data in places_data] + mock_api.places = mock_places + mock_api.fetch_places.return_value = None + + mock_forecast = Forecast.from_dict(forecast_data) + + mock_api.get_forecast.return_value = mock_forecast + + # Mock get_nearest_place to return Vilnius + mock_api.get_nearest_place.return_value = mock_places[0] + + yield mock_api + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.meteo_lt.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Vilnius", + data={CONF_PLACE_CODE: "vilnius"}, + unique_id="vilnius", + ) diff --git a/tests/components/meteo_lt/fixtures/forecast.json b/tests/components/meteo_lt/fixtures/forecast.json new file mode 100644 index 00000000000000..d289adb13944ac --- /dev/null +++ b/tests/components/meteo_lt/fixtures/forecast.json @@ -0,0 +1,53 @@ +{ + "place": { + "code": "vilnius", + "name": "Vilnius", + "administrativeDivision": "Vilniaus miesto savivaldyb\u0117", + "country": "Lietuva", + "countryCode": "LT", + "coordinates": { "latitude": 54.68705, "longitude": 25.28291 } + }, + "forecastType": "long-term", + "forecastCreationTimeUtc": "2025-09-25 08:01:29", + "forecastTimestamps": [ + { + "forecastTimeUtc": "2025-09-25 10:00:00", + "airTemperature": 10.9, + "feelsLikeTemperature": 10.9, + "windSpeed": 2, + "windGust": 6, + "windDirection": 20, + "cloudCover": 1, + "seaLevelPressure": 1033, + "relativeHumidity": 71, + "totalPrecipitation": 0, + "conditionCode": "clear" + }, + { + "forecastTimeUtc": "2025-09-25 11:00:00", + "airTemperature": 12.2, + "feelsLikeTemperature": 12.2, + "windSpeed": 2, + "windGust": 7, + "windDirection": 25, + "cloudCover": 15, + "seaLevelPressure": 1032, + "relativeHumidity": 68, + "totalPrecipitation": 0, + "conditionCode": "partly-cloudy" + }, + { + "forecastTimeUtc": "2025-09-25 12:00:00", + "airTemperature": 13.5, + "feelsLikeTemperature": 13.5, + "windSpeed": 3, + "windGust": 8, + "windDirection": 30, + "cloudCover": 25, + "seaLevelPressure": 1031, + "relativeHumidity": 65, + "totalPrecipitation": 0.1, + "conditionCode": "cloudy" + } + ] +} diff --git a/tests/components/meteo_lt/fixtures/places.json b/tests/components/meteo_lt/fixtures/places.json new file mode 100644 index 00000000000000..b5e2dcb2ca27b0 --- /dev/null +++ b/tests/components/meteo_lt/fixtures/places.json @@ -0,0 +1,35 @@ +[ + { + "code": "vilnius", + "name": "Vilnius", + "administrativeDivision": "Vilniaus miesto savivaldybė", + "country": "Lietuva", + "countryCode": "LT", + "coordinates": { + "latitude": 54.68705, + "longitude": 25.28291 + } + }, + { + "code": "kaunas", + "name": "Kaunas", + "administrativeDivision": "Kauno miesto savivaldybė", + "country": "Lietuva", + "countryCode": "LT", + "coordinates": { + "latitude": 54.90272, + "longitude": 23.95952 + } + }, + { + "code": "klaipeda", + "name": "Klaipėda", + "administrativeDivision": "Klaipėdos miesto savivaldybė", + "country": "Lietuva", + "countryCode": "LT", + "coordinates": { + "latitude": 55.70329, + "longitude": 21.14427 + } + } +] diff --git a/tests/components/meteo_lt/snapshots/test_weather.ambr b/tests/components/meteo_lt/snapshots/test_weather.ambr new file mode 100644 index 00000000000000..a3e5e911530985 --- /dev/null +++ b/tests/components/meteo_lt/snapshots/test_weather.ambr @@ -0,0 +1,64 @@ +# serializer version: 1 +# name: test_weather_entity[weather.vilnius-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'weather', + 'entity_category': None, + 'entity_id': 'weather.vilnius', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'meteo_lt', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'vilnius', + 'unit_of_measurement': None, + }) +# --- +# name: test_weather_entity[weather.vilnius-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'apparent_temperature': 10.9, + 'attribution': 'Data provided by Lithuanian Hydrometeorological Service (LHMT)', + 'cloud_coverage': 1, + 'friendly_name': 'Vilnius', + 'humidity': 71, + 'precipitation_unit': , + 'pressure': 1033.0, + 'pressure_unit': , + 'supported_features': , + 'temperature': 10.9, + 'temperature_unit': , + 'visibility_unit': , + 'wind_bearing': 20, + 'wind_gust_speed': 21.6, + 'wind_speed': 7.2, + 'wind_speed_unit': , + }), + 'context': , + 'entity_id': 'weather.vilnius', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'sunny', + }) +# --- diff --git a/tests/components/meteo_lt/test_config_flow.py b/tests/components/meteo_lt/test_config_flow.py new file mode 100644 index 00000000000000..67d9bb934b8702 --- /dev/null +++ b/tests/components/meteo_lt/test_config_flow.py @@ -0,0 +1,102 @@ +"""Test the Meteo.lt config flow.""" + +from unittest.mock import AsyncMock + +import aiohttp + +from homeassistant.components.meteo_lt.const import CONF_PLACE_CODE, DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_user_flow_success( + hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test user flow shows form and completes successfully.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert result["errors"] == {} + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PLACE_CODE: "vilnius"} + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Vilnius" + assert result["data"] == {CONF_PLACE_CODE: "vilnius"} + assert result["result"].unique_id == "vilnius" + + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test duplicate entry prevention.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_PLACE_CODE: "vilnius"} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + assert len(mock_setup_entry.mock_calls) == 0 + + +async def test_api_connection_error( + hass: HomeAssistant, mock_meteo_lt_api: AsyncMock +) -> None: + """Test API connection error during place fetching.""" + mock_meteo_lt_api.places = [] + mock_meteo_lt_api.fetch_places.side_effect = aiohttp.ClientError( + "Connection failed" + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_api_timeout_error( + hass: HomeAssistant, mock_meteo_lt_api: AsyncMock +) -> None: + """Test API timeout error during place fetching.""" + mock_meteo_lt_api.places = [] + mock_meteo_lt_api.fetch_places.side_effect = TimeoutError("Request timed out") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "cannot_connect" + + +async def test_no_places_found( + hass: HomeAssistant, mock_meteo_lt_api: AsyncMock +) -> None: + """Test when API returns no places.""" + mock_meteo_lt_api.places = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "no_places_found" diff --git a/tests/components/meteo_lt/test_weather.py b/tests/components/meteo_lt/test_weather.py new file mode 100644 index 00000000000000..27a6c549c03737 --- /dev/null +++ b/tests/components/meteo_lt/test_weather.py @@ -0,0 +1,36 @@ +"""Test Meteo.lt weather entity.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.fixture(autouse=True) +def override_platforms() -> Generator[None]: + """Override PLATFORMS.""" + with patch("homeassistant.components.meteo_lt.PLATFORMS", [Platform.WEATHER]): + yield + + +@pytest.mark.freeze_time("2025-09-25 10:00:00") +async def test_weather_entity( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, + snapshot: SnapshotAssertion, +) -> None: + """Test weather entity.""" + mock_config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/portainer/test_init.py b/tests/components/portainer/test_init.py index 00b4d5940e935e..4e661e225055b3 100644 --- a/tests/components/portainer/test_init.py +++ b/tests/components/portainer/test_init.py @@ -11,7 +11,13 @@ from homeassistant.components.portainer.const import DOMAIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_API_KEY, CONF_API_TOKEN, CONF_HOST, CONF_URL +from homeassistant.const import ( + CONF_API_KEY, + CONF_API_TOKEN, + CONF_HOST, + CONF_URL, + CONF_VERIFY_SSL, +) from homeassistant.core import HomeAssistant from . import setup_integration @@ -40,8 +46,8 @@ async def test_setup_exceptions( assert mock_config_entry.state == expected_state -async def test_v1_migration(hass: HomeAssistant) -> None: - """Test migration from v1 to v2 config entry.""" +async def test_migrations(hass: HomeAssistant) -> None: + """Test migration from v1 config entry.""" entry = MockConfigEntry( domain=DOMAIN, data={ @@ -52,11 +58,14 @@ async def test_v1_migration(hass: HomeAssistant) -> None: version=1, ) entry.add_to_hass(hass) + assert entry.version == 1 + assert CONF_VERIFY_SSL not in entry.data await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() - assert entry.version == 2 + assert entry.version == 3 assert CONF_HOST not in entry.data assert CONF_API_KEY not in entry.data assert entry.data[CONF_URL] == "http://test_host" assert entry.data[CONF_API_TOKEN] == "test_key" + assert entry.data[CONF_VERIFY_SSL] is True diff --git a/tests/components/reolink/snapshots/test_diagnostics.ambr b/tests/components/reolink/snapshots/test_diagnostics.ambr index 360816fc683aa5..a7471475e543a6 100644 --- a/tests/components/reolink/snapshots/test_diagnostics.ambr +++ b/tests/components/reolink/snapshots/test_diagnostics.ambr @@ -196,8 +196,8 @@ 'null': 1, }), 'GetWhiteLed': dict({ - '0': 2, - 'null': 2, + '0': 3, + 'null': 3, }), 'GetZoomFocus': dict({ '0': 2, diff --git a/tests/components/roomba/conftest.py b/tests/components/roomba/conftest.py new file mode 100644 index 00000000000000..aa89ff9f56a8d6 --- /dev/null +++ b/tests/components/roomba/conftest.py @@ -0,0 +1,62 @@ +"""Fixtures for the Roomba tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +import pytest +from roombapy import Roomba + +from homeassistant.components.roomba import CONF_BLID, CONF_CONTINUOUS, DOMAIN +from homeassistant.const import CONF_DELAY, CONF_HOST, CONF_PASSWORD + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_HOST: "192.168.0.30", + CONF_BLID: "blid123", + CONF_PASSWORD: "pass123", + }, + options={ + CONF_CONTINUOUS: True, + CONF_DELAY: 10, + }, + unique_id="blid123", + ) + + +@pytest.fixture +def mock_roomba() -> Generator[AsyncMock]: + """Build a fixture for the 17Track API.""" + mock_roomba = AsyncMock(spec=Roomba, autospec=True) + mock_roomba.master_state = { + "state": { + "reported": { + "cap": {"pose": 1}, + "cleanMissionStatus": {"cycle": "none", "phase": "charge"}, + "pose": {"point": {"x": 1, "y": 2}, "theta": 90}, + "dock": {"tankLvl": 99}, + "hwPartsRev": { + "navSerialNo": "12345", + "wlan0HwAddr": "AA:BB:CC:DD:EE:FF", + }, + "sku": "980", + "name": "Test Roomba", + "softwareVer": "3.2.1", + "hardwareRev": "1.0", + "bin": {"present": True, "full": False}, + } + } + } + mock_roomba.roomba_connected = True + + with patch( + "homeassistant.components.roomba.RoombaFactory.create_roomba", + return_value=mock_roomba, + ): + yield mock_roomba diff --git a/tests/components/roomba/snapshots/test_sensor.ambr b/tests/components/roomba/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..19a1ae58d0e811 --- /dev/null +++ b/tests/components/roomba/snapshots/test_sensor.ambr @@ -0,0 +1,659 @@ +# serializer version: 1 +# name: test_entities[sensor.test_roomba_average_mission_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_average_mission_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Average mission time', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'average_mission_time', + 'unique_id': 'average_mission_time_blid123', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.test_roomba_average_mission_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Average mission time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_roomba_average_mission_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_battery-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_battery', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Battery', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'battery_blid123', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.test_roomba_battery-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'battery', + 'friendly_name': 'Test Roomba Battery', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_battery', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_battery_cycles-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_battery_cycles', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Battery cycles', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'battery_cycles', + 'unique_id': 'battery_cycles_blid123', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.test_roomba_battery_cycles-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Battery cycles', + 'state_class': , + }), + 'context': , + 'entity_id': 'sensor.test_roomba_battery_cycles', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_canceled_missions-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_canceled_missions', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Canceled missions', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'canceled_missions', + 'unique_id': 'canceled_missions_blid123', + 'unit_of_measurement': 'Missions', + }) +# --- +# name: test_entities[sensor.test_roomba_canceled_missions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Canceled missions', + 'state_class': , + 'unit_of_measurement': 'Missions', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_canceled_missions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_dock_tank_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_dock_tank_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Dock tank level', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'dock_tank_level', + 'unique_id': 'dock_tank_level_blid123', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.test_roomba_dock_tank_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Dock tank level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_dock_tank_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '99', + }) +# --- +# name: test_entities[sensor.test_roomba_failed_missions-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_failed_missions', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Failed missions', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'failed_missions', + 'unique_id': 'failed_missions_blid123', + 'unit_of_measurement': 'Missions', + }) +# --- +# name: test_entities[sensor.test_roomba_failed_missions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Failed missions', + 'state_class': , + 'unit_of_measurement': 'Missions', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_failed_missions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_last_mission_start_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_last_mission_start_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Last mission start time', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'last_mission', + 'unique_id': 'last_mission_blid123', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[sensor.test_roomba_last_mission_start_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'timestamp', + 'friendly_name': 'Test Roomba Last mission start time', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_last_mission_start_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_scrubs-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_scrubs', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Scrubs', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'scrubs_count', + 'unique_id': 'scrubs_count_blid123', + 'unit_of_measurement': 'Scrubs', + }) +# --- +# name: test_entities[sensor.test_roomba_scrubs-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Scrubs', + 'state_class': , + 'unit_of_measurement': 'Scrubs', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_scrubs', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_successful_missions-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_successful_missions', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Successful missions', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'successful_missions', + 'unique_id': 'successful_missions_blid123', + 'unit_of_measurement': 'Missions', + }) +# --- +# name: test_entities[sensor.test_roomba_successful_missions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Successful missions', + 'state_class': , + 'unit_of_measurement': 'Missions', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_successful_missions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_tank_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_tank_level', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Tank level', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'tank_level', + 'unique_id': 'tank_level_blid123', + 'unit_of_measurement': '%', + }) +# --- +# name: test_entities[sensor.test_roomba_tank_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Tank level', + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_tank_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_total_cleaned_area-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_total_cleaned_area', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 0, + }), + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaned area', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaned_area', + 'unique_id': 'total_cleaned_area_blid123', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.test_roomba_total_cleaned_area-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Total cleaned area', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_roomba_total_cleaned_area', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_total_cleaning_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_total_cleaning_time', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total cleaning time', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_cleaning_time', + 'unique_id': 'total_cleaning_time_blid123', + 'unit_of_measurement': , + }) +# --- +# name: test_entities[sensor.test_roomba_total_cleaning_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Total cleaning time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.test_roomba_total_cleaning_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[sensor.test_roomba_total_missions-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.test_roomba_total_missions', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'Total missions', + 'platform': 'roomba', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'total_missions', + 'unique_id': 'total_missions_blid123', + 'unit_of_measurement': 'Missions', + }) +# --- +# name: test_entities[sensor.test_roomba_total_missions-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Test Roomba Total missions', + 'state_class': , + 'unit_of_measurement': 'Missions', + }), + 'context': , + 'entity_id': 'sensor.test_roomba_total_missions', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/roomba/test_sensor.py b/tests/components/roomba/test_sensor.py new file mode 100644 index 00000000000000..fd56a6e9b3ff7d --- /dev/null +++ b/tests/components/roomba/test_sensor.py @@ -0,0 +1,29 @@ +"""Tests for IRobotEntity usage in Roomba sensor platform.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from tests.common import MockConfigEntry, snapshot_platform + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_entities( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_roomba: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test roomba entities.""" + with patch("homeassistant.components.roomba.PLATFORMS", [Platform.SENSOR]): + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) diff --git a/tests/components/sensor/test_init.py b/tests/components/sensor/test_init.py index 36e8ab4576f771..60eda1b9d6415b 100644 --- a/tests/components/sensor/test_init.py +++ b/tests/components/sensor/test_init.py @@ -8,6 +8,7 @@ from typing import Any from unittest.mock import patch +from freezegun.api import freeze_time import pytest from homeassistant.components import sensor @@ -475,6 +476,62 @@ async def test_restore_sensor_save_state( assert type(extra_data["native_value"]) is native_value_type +@freeze_time("2020-02-08 15:00:00") +async def test_restore_sensor_save_state_frozen_time_datetime( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test RestoreSensor.""" + entity0 = MockRestoreSensor( + name="Test", + native_value=dt_util.utcnow(), + native_unit_of_measurement=None, + device_class=SensorDeviceClass.TIMESTAMP, + ) + setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Trigger saving state + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == entity0.entity_id + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == RESTORE_DATA["datetime"] + assert type(extra_data["native_value"]) is dict + + +@freeze_time("2020-02-08 15:00:00") +async def test_restore_sensor_save_state_frozen_time_date( + hass: HomeAssistant, + hass_storage: dict[str, Any], +) -> None: + """Test RestoreSensor.""" + entity0 = MockRestoreSensor( + name="Test", + native_value=dt_util.utcnow().date(), + native_unit_of_measurement=None, + device_class=SensorDeviceClass.DATE, + ) + setup_test_component_platform(hass, sensor.DOMAIN, [entity0]) + + assert await async_setup_component(hass, "sensor", {"sensor": {"platform": "test"}}) + await hass.async_block_till_done() + + # Trigger saving state + await async_mock_restore_state_shutdown_restart(hass) + + assert len(hass_storage[RESTORE_STATE_KEY]["data"]) == 1 + state = hass_storage[RESTORE_STATE_KEY]["data"][0]["state"] + assert state["entity_id"] == entity0.entity_id + extra_data = hass_storage[RESTORE_STATE_KEY]["data"][0]["extra_data"] + assert extra_data == RESTORE_DATA["date"] + assert type(extra_data["native_value"]) is dict + + @pytest.mark.parametrize( ("native_value", "native_value_type", "extra_data", "device_class", "uom"), [ diff --git a/tests/components/sonos/conftest.py b/tests/components/sonos/conftest.py index 6831e4139c2a44..aff3bf671bc6ab 100644 --- a/tests/components/sonos/conftest.py +++ b/tests/components/sonos/conftest.py @@ -664,6 +664,9 @@ def music_library_fixture( 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) + music_library.build_album_art_full_uri = Mock( + return_value="build_album_art_full_uri.jpg" + ) return music_library @@ -740,6 +743,22 @@ def current_track_info_empty_fixture(): } +@pytest.fixture(name="current_track_info") +def current_track_info_fixture(): + """Create current_track_info fixture.""" + return { + "title": "Something", + "artist": "The Beatles", + "album": "Abbey Road", + "album_art": "http://example.com/albumart.jpg", + "position": "00:00:42", + "playlist_position": "5", + "duration": "00:02:36", + "uri": "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3", + "metadata": "NOT_IMPLEMENTED", + } + + @pytest.fixture(name="battery_info") def battery_info_fixture(): """Create battery_info fixture.""" @@ -835,6 +854,48 @@ def tv_event_fixture(soco): return SonosMockEvent(soco, soco.avTransport, variables) +@pytest.fixture(name="media_event") +def media_event_fixture(soco): + """Create media event fixture.""" + variables = { + "transport_state": "PLAYING", + "current_play_mode": "NORMAL", + "current_crossfade_mode": "0", + "number_of_tracks": "1", + "current_track": "1", + "current_section": "0", + "current_track_uri": "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3", + "current_track_duration": "360", + "current_track_meta_data": DidlMusicTrack( + album="Abbey Road", + title="Something", + parent_id="-1", + item_id="-1", + restricted=True, + resources=[], + desc=None, + album_art_uri="http://example.com/albumart.jpg", + ), + "next_track_uri": "", + "next_track_meta_data": "", + "enqueued_transport_uri": "", + "enqueued_transport_uri_meta_data": "", + "playback_storage_medium": "NETWORK", + "av_transport_uri": f"x-sonos-htastream:{soco.uid}:spdif", + "av_transport_uri_meta_data": { + "title": soco.uid, + "parent_id": "0", + "item_id": "spdif-input", + "restricted": False, + "resources": [], + "desc": None, + }, + "current_transport_actions": "Set, Play", + "current_valid_play_modes": "", + } + return SonosMockEvent(soco, soco.avTransport, variables) + + @pytest.fixture(name="zgs_discovery", scope="package") def zgs_discovery_fixture(): """Load ZoneGroupState discovery payload and return it.""" diff --git a/tests/components/sonos/snapshots/test_media_player.ambr b/tests/components/sonos/snapshots/test_media_player.ambr index 66b322ea7766e9..f47ba2f05da244 100644 --- a/tests/components/sonos/snapshots/test_media_player.ambr +++ b/tests/components/sonos/snapshots/test_media_player.ambr @@ -82,3 +82,115 @@ ]), }) # --- +# name: test_media_info_attributes[basic_track] + dict({ + 'entity_picture': '/api/media_player_proxy/media_player.zone_a?token=123456789&cache=0b180fe5758c0b7f', + 'media_album_name': 'Abbey Road', + 'media_artist': 'The Beatles', + 'media_channel': None, + 'media_content_id': 'x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3', + 'media_content_type': , + 'media_duration': 156, + 'media_playlist': None, + 'media_position': 42, + 'media_title': 'Something', + 'queue_position': 5, + 'source': None, + }) +# --- +# name: test_media_info_attributes[basic_track_no_art] + dict({ + 'entity_picture': '/api/media_player_proxy/media_player.zone_a?token=123456789&cache=0b28413f58211151', + 'media_album_name': 'Abbey Road', + 'media_artist': 'The Beatles', + 'media_channel': None, + 'media_content_id': 'x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3', + 'media_content_type': , + 'media_duration': 156, + 'media_playlist': None, + 'media_position': 42, + 'media_title': 'Something', + 'queue_position': 5, + 'source': None, + }) +# --- +# name: test_media_info_attributes[basic_track_no_position] + dict({ + 'entity_picture': '/api/media_player_proxy/media_player.zone_a?token=123456789&cache=0b180fe5758c0b7f', + 'media_album_name': 'Abbey Road', + 'media_artist': 'The Beatles', + 'media_channel': None, + 'media_content_id': 'x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3', + 'media_content_type': , + 'media_duration': None, + 'media_playlist': None, + 'media_position': None, + 'media_title': 'Something', + 'queue_position': 5, + 'source': None, + }) +# --- +# name: test_media_info_attributes[line_in] + dict({ + 'entity_picture': None, + 'media_album_name': None, + 'media_artist': None, + 'media_channel': None, + 'media_content_id': 'x-rincon-stream:0', + 'media_content_type': , + 'media_duration': None, + 'media_playlist': None, + 'media_position': None, + 'media_title': 'Line-in', + 'queue_position': None, + 'source': 'Line-in', + }) +# --- +# name: test_media_info_attributes[playlist_container] + dict({ + 'entity_picture': '/api/media_player_proxy/media_player.zone_a?token=123456789&cache=0b180fe5758c0b7f', + 'media_album_name': 'Abbey Road', + 'media_artist': 'The Beatles', + 'media_channel': None, + 'media_content_id': 'x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3', + 'media_content_type': , + 'media_duration': None, + 'media_playlist': 'My Playlist', + 'media_position': None, + 'media_title': 'Something', + 'queue_position': 5, + 'source': None, + }) +# --- +# name: test_media_info_attributes[radio_station] + dict({ + 'entity_picture': '/api/media_player_proxy/media_player.zone_a?token=123456789&cache=0b180fe5758c0b7f', + 'media_album_name': None, + 'media_artist': None, + 'media_channel': 'World News', + 'media_content_id': 'x-sonosapi-stream:1234', + 'media_content_type': , + 'media_duration': 156, + 'media_playlist': None, + 'media_position': 42, + 'media_title': 'World News', + 'queue_position': None, + 'source': None, + }) +# --- +# name: test_media_info_attributes[radio_station_with_show] + dict({ + 'entity_picture': '/api/media_player_proxy/media_player.zone_a?token=123456789&cache=0b180fe5758c0b7f', + 'media_album_name': None, + 'media_artist': None, + 'media_channel': 'World News • Live at 6', + 'media_content_id': 'x-sonosapi-stream:1234', + 'media_content_type': , + 'media_duration': 156, + 'media_playlist': None, + 'media_position': 42, + 'media_title': 'World News • Live at 6', + 'queue_position': None, + 'source': None, + }) +# --- diff --git a/tests/components/sonos/test_media_player.py b/tests/components/sonos/test_media_player.py index e751fafca24249..f1ce2496837d0b 100644 --- a/tests/components/sonos/test_media_player.py +++ b/tests/components/sonos/test_media_player.py @@ -1,23 +1,39 @@ """Tests for the Sonos Media Player platform.""" +from collections.abc import Generator +from datetime import UTC, datetime from typing import Any -from unittest.mock import patch +from unittest.mock import MagicMock, patch +from freezegun import freeze_time import pytest -from soco.data_structures import SearchResult +from soco.data_structures import ( + DidlAudioBroadcast, + DidlAudioLineIn, + DidlPlaylistContainer, + SearchResult, +) from sonos_websocket.exception import SonosWebsocketError from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import ( ATTR_INPUT_SOURCE, ATTR_INPUT_SOURCE_LIST, + ATTR_MEDIA_ALBUM_NAME, ATTR_MEDIA_ANNOUNCE, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_CHANNEL, ATTR_MEDIA_CONTENT_ID, ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, ATTR_MEDIA_ENQUEUE, ATTR_MEDIA_EXTRA, + ATTR_MEDIA_PLAYLIST, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_POSITION_UPDATED_AT, ATTR_MEDIA_REPEAT, ATTR_MEDIA_SHUFFLE, + ATTR_MEDIA_TITLE, ATTR_MEDIA_VOLUME_LEVEL, DOMAIN as MP_DOMAIN, SERVICE_CLEAR_PLAYLIST, @@ -40,6 +56,7 @@ ATTR_ALARM_ID, ATTR_ENABLED, ATTR_INCLUDE_LINKED_ZONES, + ATTR_QUEUE_POSITION, ATTR_VOLUME, SERVICE_GET_QUEUE, SERVICE_RESTORE, @@ -48,6 +65,7 @@ ) from homeassistant.const import ( ATTR_ENTITY_ID, + ATTR_ENTITY_PICTURE, ATTR_TIME, SERVICE_MEDIA_NEXT_TRACK, SERVICE_MEDIA_PAUSE, @@ -73,6 +91,13 @@ from .conftest import MockMusicServiceItem, MockSoCo, SoCoMockFactory, SonosMockEvent +@pytest.fixture(autouse=True) +def mock_token() -> Generator[MagicMock]: + """Mock token generator.""" + with patch("secrets.token_hex", return_value="123456789") as token: + yield token + + async def test_device_registry( hass: HomeAssistant, device_registry: DeviceRegistry, async_autosetup_sonos, soco ) -> None: @@ -1347,3 +1372,208 @@ async def test_service_update_alarm_dne( blocking=True, ) assert soco.alarmClock.UpdateAlarm.call_count == 0 + + +@pytest.mark.freeze_time("2024-01-01T12:00:00Z") +async def test_position_updates( + hass: HomeAssistant, + soco: MockSoCo, + async_autosetup_sonos, + media_event: SonosMockEvent, + current_track_info: dict[str, Any], +) -> None: + """Test the media player position updates.""" + + soco.get_current_track_info.return_value = current_track_info + soco.avTransport.subscribe.return_value.callback(media_event) + await hass.async_block_till_done(wait_background_tasks=True) + + entity_id = "media_player.zone_a" + state = hass.states.get(entity_id) + + assert state.attributes[ATTR_MEDIA_POSITION] == 42 + # updated_at should be recent + updated_at = state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] + assert updated_at == datetime.now(UTC) + + # Position only updated by 1 second; should not update attributes + new_track_info = current_track_info.copy() + new_track_info["position"] = "00:00:43" + soco.get_current_track_info.return_value = new_track_info + new_media_event = SonosMockEvent( + soco, soco.avTransport, media_event.variables.copy() + ) + new_media_event.variables["position"] = "00:00:43" + with freeze_time("2024-01-01T12:00:01Z"): + soco.avTransport.subscribe.return_value.callback(new_media_event) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_MEDIA_POSITION] == 42 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == updated_at + + # Position jumped by more than 1.5 seconds; should update position + new_track_info = current_track_info.copy() + new_track_info["position"] = "00:01:10" + soco.get_current_track_info.return_value = new_track_info + new_media_event = SonosMockEvent( + soco, soco.avTransport, media_event.variables.copy() + ) + new_media_event.variables["position"] = "00:01:10" + with freeze_time("2024-01-01T12:00:11Z"): + soco.avTransport.subscribe.return_value.callback(new_media_event) + await hass.async_block_till_done(wait_background_tasks=True) + state = hass.states.get(entity_id) + assert state.attributes[ATTR_MEDIA_POSITION] == 70 + assert state.attributes[ATTR_MEDIA_POSITION_UPDATED_AT] == datetime.now(UTC) + + +@pytest.mark.parametrize( + ("track_info", "event_variables"), + [ + ( + { + "title": "Something", + "artist": "The Beatles", + "album": "Abbey Road", + "album_art": "http://example.com/albumart.jpg", + "position": "00:00:42", + "playlist_position": "5", + "duration": "00:02:36", + "uri": "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3", + "metadata": "NOT_IMPLEMENTED", + }, + {}, + ), + ( + { + "title": "Something", + "artist": "The Beatles", + "album": "Abbey Road", + "position": "00:00:42", + "playlist_position": "5", + "duration": "00:02:36", + "uri": "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3", + "metadata": "NOT_IMPLEMENTED", + }, + {}, + ), + ( + { + "title": "Something", + "artist": "The Beatles", + "album": "Abbey Road", + "album_art": "http://example.com/albumart.jpg", + "playlist_position": "5", + "uri": "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3", + "metadata": "NOT_IMPLEMENTED", + }, + {}, + ), + ( + { + "uri": "x-rincon-stream:0", + "metadata": "NOT_IMPLEMENTED", + }, + { + "current_track_uri": "x-rincon-stream:0", + "current_track_meta_data": DidlAudioLineIn("Line-in", "-1", "-1"), + }, + ), + ( + { + "title": "Something", + "artist": "The Beatles", + "album": "Abbey Road", + "album_art": "http://example.com/albumart.jpg", + "playlist_position": "5", + "uri": "x-file-cifs://192.168.42.10/music/The%20Beatles/Abbey%20Road/03%20Something.mp3", + "metadata": "NOT_IMPLEMENTED", + }, + { + "enqueued_transport_uri_meta_data": DidlPlaylistContainer( + "My Playlist", "-1", "-1" + ) + }, + ), + ( + { + "album_art": "http://example.com/albumart.jpg", + "position": "00:00:42", + "duration": "00:02:36", + "uri": "x-sonosapi-stream:1234", + "metadata": "NOT_IMPLEMENTED", + }, + { + "enqueued_transport_uri_meta_data": DidlAudioBroadcast( + "World News", "-1", "-1" + ), + "current_track_uri": "x-sonosapi-stream:1234", + }, + ), + ( + { + "album_art": "http://example.com/albumart.jpg", + "position": "00:00:42", + "duration": "00:02:36", + "uri": "x-sonosapi-stream:1234", + "metadata": "NOT_IMPLEMENTED", + }, + { + "enqueued_transport_uri_meta_data": DidlAudioBroadcast( + "World News", "-1", "-1" + ), + "current_track_uri": "x-sonosapi-stream:1234", + "current_track_meta_data": DidlAudioBroadcast( + "World News", "-1", "-1", radio_show="Live at 6" + ), + }, + ), + ], + ids=[ + "basic_track", + "basic_track_no_art", + "basic_track_no_position", + "line_in", + "playlist_container", + "radio_station", + "radio_station_with_show", + ], +) +async def test_media_info_attributes( + hass: HomeAssistant, + soco: MockSoCo, + async_autosetup_sonos, + media_event: SonosMockEvent, + track_info: dict[str, Any], + event_variables: dict[str, Any], + snapshot: SnapshotAssertion, +) -> None: + """Test the media player info attributes using a variety of inputs.""" + media_event.variables.update(event_variables) + soco.get_current_track_info.return_value = track_info + soco.avTransport.subscribe.return_value.callback(media_event) + + await hass.async_block_till_done(wait_background_tasks=True) + + state = hass.states.get("media_player.zone_a") + + snapshot_keys = [ + ATTR_MEDIA_ALBUM_NAME, + ATTR_MEDIA_ARTIST, + ATTR_MEDIA_CONTENT_ID, + ATTR_MEDIA_CONTENT_TYPE, + ATTR_MEDIA_DURATION, + ATTR_MEDIA_POSITION, + ATTR_MEDIA_TITLE, + ATTR_QUEUE_POSITION, + ATTR_ENTITY_PICTURE, + ATTR_INPUT_SOURCE, + ATTR_MEDIA_PLAYLIST, + ATTR_MEDIA_CHANNEL, + ] + + # Create a filtered dict of only those attributes + filtered_attrs = {k: state.attributes.get(k) for k in snapshot_keys} + + # Use the snapshot assertion + assert filtered_attrs == snapshot diff --git a/tests/components/sonos/test_select.py b/tests/components/sonos/test_select.py index dbbf28a52d74a2..0a50da9b9a7d9a 100644 --- a/tests/components/sonos/test_select.py +++ b/tests/components/sonos/test_select.py @@ -88,6 +88,36 @@ async def test_select_dialog_invalid_level( assert dialog_level_state.state == STATE_UNKNOWN +@pytest.mark.parametrize( + ("value", "result"), + [ + ("invalid_integer", "Invalid value for dialog_level_enum invalid_integer"), + (None, "Missing value for dialog_level_enum"), + ], +) +async def test_select_dialog_value_error( + hass: HomeAssistant, + async_setup_sonos, + soco, + entity_registry: er.EntityRegistry, + speaker_info: dict[str, str], + caplog: pytest.LogCaptureFixture, + value: str | None, + result: str, +) -> None: + """Test receiving a value from Sonos that is not convertible to an integer.""" + + speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() + soco.get_speaker_info.return_value = speaker_info + soco.dialog_level = value + + with caplog.at_level(logging.WARNING): + await async_setup_sonos() + assert result in caplog.text + + assert SELECT_DIALOG_LEVEL_ENTITY not in entity_registry.entities + + @pytest.mark.parametrize( ("result", "option"), [ @@ -149,12 +179,12 @@ async def test_select_dialog_level_event( speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() soco.get_speaker_info.return_value = speaker_info - soco.dialog_level = 0 + soco.dialog_level = "0" await async_setup_sonos() event = create_rendering_control_event(soco) - event.variables[ATTR_DIALOG_LEVEL] = 3 + event.variables[ATTR_DIALOG_LEVEL] = "3" soco.renderingControl.subscribe.return_value._callback(event) await hass.async_block_till_done(wait_background_tasks=True) @@ -175,11 +205,11 @@ async def test_select_dialog_level_poll( speaker_info["model_name"] = MODEL_SONOS_ARC_ULTRA.lower() soco.get_speaker_info.return_value = speaker_info - soco.dialog_level = 0 + soco.dialog_level = "0" await async_setup_sonos() - soco.dialog_level = 4 + soco.dialog_level = "4" freezer.tick(SCAN_INTERVAL) async_fire_time_changed(hass) diff --git a/tests/components/switchbot_cloud/test_lock.py b/tests/components/switchbot_cloud/test_lock.py index ca41f6eb99f16d..dfafda4110f5de 100644 --- a/tests/components/switchbot_cloud/test_lock.py +++ b/tests/components/switchbot_cloud/test_lock.py @@ -7,7 +7,12 @@ from homeassistant.components.lock import DOMAIN as LOCK_DOMAIN, LockState from homeassistant.components.switchbot_cloud import SwitchBotAPI from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_ENTITY_ID, SERVICE_LOCK, SERVICE_UNLOCK +from homeassistant.const import ( + ATTR_ENTITY_ID, + SERVICE_LOCK, + SERVICE_OPEN, + SERVICE_UNLOCK, +) from homeassistant.core import HomeAssistant from . import configure_integration @@ -45,3 +50,33 @@ async def test_lock(hass: HomeAssistant, mock_list_devices, mock_get_status) -> LOCK_DOMAIN, SERVICE_LOCK, {ATTR_ENTITY_ID: lock_id}, blocking=True ) assert hass.states.get(lock_id).state == LockState.LOCKED + + +async def test_lock_open( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test lock open.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="lock-id-1", + deviceName="lock-1", + deviceType="Smart Lock Pro", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"lockState": "locked"} + + entry = await configure_integration(hass) + + assert entry.state is ConfigEntryState.LOADED + + lock_id = "lock.lock_1" + assert hass.states.get(lock_id).state == LockState.LOCKED + + with patch.object(SwitchBotAPI, "send_command"): + await hass.services.async_call( + LOCK_DOMAIN, SERVICE_OPEN, {ATTR_ENTITY_ID: lock_id}, blocking=True + ) + assert hass.states.get(lock_id).state == LockState.UNLOCKED diff --git a/tests/components/switchbot_cloud/test_switch.py b/tests/components/switchbot_cloud/test_switch.py index 9bd93342baecc0..67d0d516713f09 100644 --- a/tests/components/switchbot_cloud/test_switch.py +++ b/tests/components/switchbot_cloud/test_switch.py @@ -13,6 +13,7 @@ SERVICE_TURN_ON, STATE_OFF, STATE_ON, + STATE_UNKNOWN, ) from homeassistant.core import HomeAssistant @@ -108,3 +109,83 @@ async def test_pressmode_bot_no_switch_entity( entry = await configure_integration(hass) assert entry.state is ConfigEntryState.LOADED assert not hass.states.async_entity_ids(SWITCH_DOMAIN) + + +async def test_switch_relay_2pm_turn_on( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test switch relay 2pm turn on.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="relay-switch-id-1", + deviceName="relay-switch-1", + deviceType="Relay Switch 2PM", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"switchStatus": 0} + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + entity_id = "switch.relay_switch_1_channel_1" + assert hass.states.get(entity_id).state == STATE_OFF + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_once() + + +async def test_switch_relay_2pm_turn_off( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test switch relay 2pm turn off.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="relay-switch-id-1", + deviceName="relay-switch-1", + deviceType="Relay Switch 2PM", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = {"switchStatus": 0} + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + entity_id = "switch.relay_switch_1_channel_1" + assert hass.states.get(entity_id).state == STATE_OFF + + with patch.object(SwitchBotAPI, "send_command") as mock_send_command: + await hass.services.async_call( + SWITCH_DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: entity_id}, blocking=True + ) + mock_send_command.assert_called_once() + + +async def test_switch_relay_2pm_coordination_is_none( + hass: HomeAssistant, mock_list_devices, mock_get_status +) -> None: + """Test switch relay 2pm coordination is none.""" + mock_list_devices.return_value = [ + Device( + version="V1.0", + deviceId="relay-switch-id-1", + deviceName="relay-switch-1", + deviceType="Relay Switch 2PM", + hubDeviceId="test-hub-id", + ), + ] + + mock_get_status.return_value = None + + entry = await configure_integration(hass) + assert entry.state is ConfigEntryState.LOADED + + entity_id = "switch.relay_switch_1_channel_1" + assert hass.states.get(entity_id).state == STATE_UNKNOWN diff --git a/tests/components/template/test_binary_sensor.py b/tests/components/template/test_binary_sensor.py index b30051a52d2664..575bad4b9425fe 100644 --- a/tests/components/template/test_binary_sensor.py +++ b/tests/components/template/test_binary_sensor.py @@ -1464,7 +1464,7 @@ async def test_saving_auto_off( freezer.move_to("2022-02-02 02:02:00+00:00") fake_extra_data = { "auto_off_time": { - "__type": "", + "__type": "", "isoformat": "2022-02-02T02:02:02+00:00", }, } diff --git a/tests/components/vegehub/conftest.py b/tests/components/vegehub/conftest.py index 6e48feb4271457..feae5deccbe3e9 100644 --- a/tests/components/vegehub/conftest.py +++ b/tests/components/vegehub/conftest.py @@ -28,7 +28,7 @@ "first_boot": False, "page_updated": False, "error_message": 0, - "num_channels": 2, + "num_channels": 4, "num_actuators": 2, "version": "3.4.5", "agenda": 1, @@ -57,7 +57,7 @@ def mock_vegehub() -> Generator[Any, Any, Any]: mock_instance.unique_id = TEST_UNIQUE_ID mock_instance.url = f"http://{TEST_IP}" mock_instance.info = load_fixture("vegehub/info_hub.json") - mock_instance.num_sensors = 2 + mock_instance.num_sensors = 4 mock_instance.num_actuators = 2 mock_instance.sw_version = "3.4.5" diff --git a/tests/components/vegehub/snapshots/test_sensor.ambr b/tests/components/vegehub/snapshots/test_sensor.ambr index 3a9a93dc03bb0e..6fb0ef67c50a0c 100644 --- a/tests/components/vegehub/snapshots/test_sensor.ambr +++ b/tests/components/vegehub/snapshots/test_sensor.ambr @@ -49,7 +49,7 @@ 'last_changed': , 'last_reported': , 'last_updated': , - 'state': '1.330000043', + 'state': '9.314800262', }) # --- # name: test_sensor_entities[sensor.vegehub_input_1-entry] @@ -158,3 +158,109 @@ 'state': '1.45599997', }) # --- +# name: test_sensor_entities[sensor.vegehub_input_3-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vegehub_input_3', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 3', + 'platform': 'vegehub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'analog_sensor', + 'unique_id': 'A1B2C3D4E5F6_analog_2', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[sensor.vegehub_input_3-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'VegeHub Input 3', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vegehub_input_3', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.330000043', + }) +# --- +# name: test_sensor_entities[sensor.vegehub_input_4-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.vegehub_input_4', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Input 4', + 'platform': 'vegehub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'analog_sensor', + 'unique_id': 'A1B2C3D4E5F6_analog_3', + 'unit_of_measurement': , + }) +# --- +# name: test_sensor_entities[sensor.vegehub_input_4-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'voltage', + 'friendly_name': 'VegeHub Input 4', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.vegehub_input_4', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.075999998', + }) +# --- diff --git a/tests/components/vegehub/snapshots/test_switch.ambr b/tests/components/vegehub/snapshots/test_switch.ambr new file mode 100644 index 00000000000000..ea6d0f817914ab --- /dev/null +++ b/tests/components/vegehub/snapshots/test_switch.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_switch_entities[switch.vegehub_actuator_1-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.vegehub_actuator_1', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Actuator 1', + 'platform': 'vegehub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'A1B2C3D4E5F6_actuator_0', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.vegehub_actuator_1-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'VegeHub Actuator 1', + }), + 'context': , + 'entity_id': 'switch.vegehub_actuator_1', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_switch_entities[switch.vegehub_actuator_2-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.vegehub_actuator_2', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Actuator 2', + 'platform': 'vegehub', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'switch', + 'unique_id': 'A1B2C3D4E5F6_actuator_1', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_entities[switch.vegehub_actuator_2-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'switch', + 'friendly_name': 'VegeHub Actuator 2', + }), + 'context': , + 'entity_id': 'switch.vegehub_actuator_2', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- diff --git a/tests/components/vegehub/test_switch.py b/tests/components/vegehub/test_switch.py new file mode 100644 index 00000000000000..ab9768b814909e --- /dev/null +++ b/tests/components/vegehub/test_switch.py @@ -0,0 +1,107 @@ +"""Unit tests for the VegeHub integration's switch.py.""" + +from unittest.mock import patch + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import init_integration +from .conftest import TEST_SIMPLE_MAC, TEST_WEBHOOK_ID + +from tests.common import MockConfigEntry, snapshot_platform +from tests.typing import ClientSessionGenerator + +UPDATE_DATA = { + "api_key": "", + "mac": TEST_SIMPLE_MAC, + "error_code": 0, + "sensors": [ + {"slot": 1, "samples": [{"v": 1.5, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 2, "samples": [{"v": 1.45599997, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 3, "samples": [{"v": 1.330000043, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 4, "samples": [{"v": 0.075999998, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 5, "samples": [{"v": 9.314800262, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 6, "samples": [{"v": 1, "t": "2025-01-15T16:51:23Z"}]}, + {"slot": 7, "samples": [{"v": 0, "t": "2025-01-15T16:51:23Z"}]}, + ], + "send_time": 1736959883, + "wifi_str": -27, +} + + +async def test_switch_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + hass_client_no_auth: ClientSessionGenerator, + entity_registry: er.EntityRegistry, + mocked_config_entry: MockConfigEntry, +) -> None: + """Test all entities.""" + + with patch("homeassistant.components.vegehub.PLATFORMS", [Platform.SWITCH]): + await init_integration(hass, mocked_config_entry) + + assert TEST_WEBHOOK_ID in hass.data["webhook"], "Webhook was not registered" + + # Verify the webhook handler + webhook_info = hass.data["webhook"][TEST_WEBHOOK_ID] + assert webhook_info["handler"], "Webhook handler is not set" + + client = await hass_client_no_auth() + resp = await client.post(f"/api/webhook/{TEST_WEBHOOK_ID}", json=UPDATE_DATA) + + # Send the same update again so that the coordinator modifies existing data + # instead of creating new data. + resp = await client.post(f"/api/webhook/{TEST_WEBHOOK_ID}", json=UPDATE_DATA) + + # Wait for remaining tasks to complete. + await hass.async_block_till_done() + assert resp.status == 200, f"Unexpected status code: {resp.status}" + await snapshot_platform( + hass, entity_registry, snapshot, mocked_config_entry.entry_id + ) + + +async def test_switch_turn_on_off( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + mocked_config_entry: MockConfigEntry, +) -> None: + """Test switch turn_on and turn_off methods.""" + with patch("homeassistant.components.vegehub.PLATFORMS", [Platform.SWITCH]): + await init_integration(hass, mocked_config_entry) + + # Send webhook data to initialize switches + client = await hass_client_no_auth() + resp = await client.post(f"/api/webhook/{TEST_WEBHOOK_ID}", json=UPDATE_DATA) + await hass.async_block_till_done() + assert resp.status == 200 + + # Get switch entity IDs + switch_entity_ids = hass.states.async_entity_ids("switch") + assert len(switch_entity_ids) > 0, "No switch entities found" + + # Test turn_on method + with patch( + "homeassistant.components.vegehub.VegeHub.set_actuator" + ) as mock_set_actuator: + await hass.services.async_call( + "switch", "turn_on", {"entity_id": switch_entity_ids[0]}, blocking=True + ) + mock_set_actuator.assert_called_once_with( + 1, 0, 600 + ) # on, index 0, duration 600 + + # Test turn_off method + with patch( + "homeassistant.components.vegehub.VegeHub.set_actuator" + ) as mock_set_actuator: + await hass.services.async_call( + "switch", "turn_off", {"entity_id": switch_entity_ids[0]}, blocking=True + ) + mock_set_actuator.assert_called_once_with( + 0, 0, 600 + ) # off, index 0, duration 600 diff --git a/tests/conftest.py b/tests/conftest.py index 05714d71a22a93..50bf0c40e10f53 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -186,11 +186,13 @@ def pytest_runtest_setup() -> None: destinations will be allowed. freezegun: - Modified to include https://github.com/spulec/freezegun/pull/424 + Modified to include https://github.com/spulec/freezegun/pull/424 and improve class str. """ pytest_socket.socket_allow_hosts(["127.0.0.1"]) pytest_socket.disable_socket(allow_unix_socket=True) + freezegun.api.FakeDate = patch_time.HAFakeDate # type: ignore[attr-defined] + freezegun.api.datetime_to_fakedatetime = patch_time.ha_datetime_to_fakedatetime # type: ignore[attr-defined] freezegun.api.FakeDatetime = patch_time.HAFakeDatetime # type: ignore[attr-defined] diff --git a/tests/helpers/test_service.py b/tests/helpers/test_service.py index e61d1382af28ef..e7cf2c61a7660d 100644 --- a/tests/helpers/test_service.py +++ b/tests/helpers/test_service.py @@ -1419,7 +1419,7 @@ async def test_call_with_required_features(hass: HomeAssistant, mock_entities) - mock_entities["light.bedroom"], ] actual = [call[0][0] for call in test_service_mock.call_args_list] - assert all(entity in actual for entity in expected) + assert actual == unordered(expected) # Test we raise if we target entity ID that does not support the service test_service_mock.reset_mock() @@ -1479,7 +1479,7 @@ async def test_call_with_one_of_required_features( mock_entities["light.bathroom"], ] actual = [call[0][0] for call in test_service_mock.call_args_list] - assert all(entity in actual for entity in expected) + assert actual == unordered(expected) @pytest.mark.parametrize( diff --git a/tests/patch_time.py b/tests/patch_time.py index 76d31d6a75a4e2..c61e6291740179 100644 --- a/tests/patch_time.py +++ b/tests/patch_time.py @@ -26,8 +26,31 @@ def ha_datetime_to_fakedatetime(datetime) -> freezegun.api.FakeDatetime: # type ) -class HAFakeDatetime(freezegun.api.FakeDatetime): # type: ignore[name-defined] - """Modified to include https://github.com/spulec/freezegun/pull/424.""" +class HAFakeDateMeta(freezegun.api.FakeDateMeta): + """Modified to override the string representation.""" + + def __str__(cls) -> str: # noqa: N805 (ruff doesn't know this is a metaclass) + """Return the string representation of the class.""" + return "" + + +class HAFakeDate(freezegun.api.FakeDate, metaclass=HAFakeDateMeta): # type: ignore[name-defined] + """Modified to improve class str.""" + + +class HAFakeDatetimeMeta(freezegun.api.FakeDatetimeMeta): + """Modified to override the string representation.""" + + def __str__(cls) -> str: # noqa: N805 (ruff doesn't know this is a metaclass) + """Return the string representation of the class.""" + return "" + + +class HAFakeDatetime(freezegun.api.FakeDatetime, metaclass=HAFakeDatetimeMeta): # type: ignore[name-defined] + """Modified to include basic fold support and improve class str. + + Fold support submitted to upstream in https://github.com/spulec/freezegun/pull/424. + """ @classmethod def now(cls, tz=None):