diff --git a/homeassistant/components/devolo_home_network/device_tracker.py b/homeassistant/components/devolo_home_network/device_tracker.py index a0cdd3812612df..d691cc130078fe 100644 --- a/homeassistant/components/devolo_home_network/device_tracker.py +++ b/homeassistant/components/devolo_home_network/device_tracker.py @@ -80,8 +80,7 @@ def restore_entities() -> None: ) -# The pylint disable is needed because of https://github.com/pylint-dev/pylint/issues/9138 -class DevoloScannerEntity( # pylint: disable=hass-enforce-class-module +class DevoloScannerEntity( CoordinatorEntity[DevoloDataUpdateCoordinator[dict[str, ConnectedStationInfo]]], ScannerEntity, ): diff --git a/homeassistant/components/sma/sensor.py b/homeassistant/components/sma/sensor.py index 49cba402b611d3..1c103b53cc4d25 100644 --- a/homeassistant/components/sma/sensor.py +++ b/homeassistant/components/sma/sensor.py @@ -873,6 +873,7 @@ def __init__( url = f"{protocol}://{entry.data[CONF_HOST]}" self._sensor = pysma_sensor + self._serial = coordinator.data.sma_device_info["serial"] assert entry.unique_id self._attr_device_info = DeviceInfo( @@ -902,6 +903,14 @@ def name(self) -> str: return f"{name_prefix} {super().name}" + @property + def available(self) -> bool: + """Return if the device is available.""" + return ( + super().available + and self._serial == self.coordinator.data.sma_device_info["serial"] + ) + @property def native_value(self) -> StateType: """Return the state of the sensor.""" diff --git a/homeassistant/components/volvo/button.py b/homeassistant/components/volvo/button.py index be290f47dabda4..b1f0b067d7b401 100644 --- a/homeassistant/components/volvo/button.py +++ b/homeassistant/components/volvo/button.py @@ -24,6 +24,7 @@ class VolvoButtonDescription(VolvoEntityDescription, ButtonEntityDescription): api_command: str required_command_key: str + data: dict[str, int] | None = None _DESCRIPTIONS: tuple[VolvoButtonDescription, ...] = ( @@ -37,6 +38,17 @@ class VolvoButtonDescription(VolvoEntityDescription, ButtonEntityDescription): api_command="climatization-stop", required_command_key="CLIMATIZATION_STOP", ), + VolvoButtonDescription( + key="engine_start", + api_command="engine-start", + required_command_key="ENGINE_START", + data={"runtimeMinutes": 15}, + ), + VolvoButtonDescription( + key="engine_stop", + api_command="engine-stop", + required_command_key="ENGINE_STOP", + ), VolvoButtonDescription( key="flash", api_command="flash", @@ -84,7 +96,7 @@ async def async_press(self) -> None: try: result = await self.entry.runtime_data.context.api.async_execute_command( - self.entity_description.api_command + self.entity_description.api_command, self.entity_description.data ) except VolvoApiException as ex: _LOGGER.debug("Command '%s' error", command) diff --git a/homeassistant/components/volvo/icons.json b/homeassistant/components/volvo/icons.json index 72f766553c6067..e898f3deed551e 100644 --- a/homeassistant/components/volvo/icons.json +++ b/homeassistant/components/volvo/icons.json @@ -267,6 +267,12 @@ "climatization_stop": { "default": "mdi:air-conditioner" }, + "engine_start": { + "default": "mdi:engine" + }, + "engine_stop": { + "default": "mdi:engine-off" + }, "flash": { "default": "mdi:alarm-light-outline" }, diff --git a/homeassistant/components/volvo/strings.json b/homeassistant/components/volvo/strings.json index a518fc92514c67..1a1c4499c30694 100644 --- a/homeassistant/components/volvo/strings.json +++ b/homeassistant/components/volvo/strings.json @@ -194,6 +194,12 @@ "climatization_stop": { "name": "Stop climatization" }, + "engine_start": { + "name": "Start engine" + }, + "engine_stop": { + "name": "Stop engine" + }, "flash": { "name": "Flash" }, diff --git a/tests/components/sma/test_init.py b/tests/components/sma/test_init.py index 57c3cab33e7894..0c20a1acc4340d 100644 --- a/tests/components/sma/test_init.py +++ b/tests/components/sma/test_init.py @@ -1,12 +1,19 @@ """Test the sma init file.""" -from collections.abc import AsyncGenerator +from collections.abc import AsyncGenerator, Generator + +from pysma.exceptions import ( + SmaAuthenticationException, + SmaConnectionException, + SmaReadException, +) +import pytest from homeassistant.components.sma.const import DOMAIN -from homeassistant.config_entries import SOURCE_IMPORT +from homeassistant.config_entries import SOURCE_IMPORT, ConfigEntryState from homeassistant.core import HomeAssistant -from . import MOCK_DEVICE, MOCK_USER_INPUT +from . import MOCK_DEVICE, MOCK_USER_INPUT, setup_integration from tests.common import MockConfigEntry @@ -30,3 +37,24 @@ async def test_migrate_entry_minor_version_1_2( assert entry.version == 1 assert entry.minor_version == 2 assert entry.unique_id == str(MOCK_DEVICE["serial"]) + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (SmaConnectionException, ConfigEntryState.SETUP_RETRY), + (SmaAuthenticationException, ConfigEntryState.SETUP_ERROR), + (SmaReadException, ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_exceptions( + hass: HomeAssistant, + mock_sma_client: Generator, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test the _async_setup.""" + mock_sma_client.device_info.side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state == expected_state diff --git a/tests/components/sma/test_sensor.py b/tests/components/sma/test_sensor.py index 8199e8fc163457..d289962d798190 100644 --- a/tests/components/sma/test_sensor.py +++ b/tests/components/sma/test_sensor.py @@ -3,16 +3,25 @@ from collections.abc import Generator from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory +from pysma.exceptions import ( + SmaAuthenticationException, + SmaConnectionException, + SmaReadException, +) import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.sma.const import DEFAULT_SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util from . import setup_integration -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -32,3 +41,34 @@ async def test_all_entities( await snapshot_platform( hass, entity_registry, snapshot, mock_config_entry.entry_id ) + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +@pytest.mark.parametrize( + ("exception"), + [ + (SmaConnectionException), + (SmaAuthenticationException), + (SmaReadException), + (Exception), + ], +) +async def test_refresh_exceptions( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_sma_client: Generator, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """Test the coordinator refresh exceptions.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_sma_client.read.side_effect = exception + + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get("sensor.sma_device_name_battery_capacity_a") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/volvo/snapshots/test_button.ambr b/tests/components/volvo/snapshots/test_button.ambr index 38e3564600db37..d67fc12f2ce265 100644 --- a/tests/components/volvo/snapshots/test_button.ambr +++ b/tests/components/volvo/snapshots/test_button.ambr @@ -911,6 +911,54 @@ 'state': 'unknown', }) # --- +# name: test_button[xc90_petrol_2019][button.volvo_xc90_start_engine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_xc90_start_engine', + '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': 'Start engine', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_start', + 'unique_id': 'yv1abcdefg1234567_engine_start', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[xc90_petrol_2019][button.volvo_xc90_start_engine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Start engine', + }), + 'context': , + 'entity_id': 'button.volvo_xc90_start_engine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_button[xc90_petrol_2019][button.volvo_xc90_stop_climatization-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ @@ -959,3 +1007,51 @@ 'state': 'unknown', }) # --- +# name: test_button[xc90_petrol_2019][button.volvo_xc90_stop_engine-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.volvo_xc90_stop_engine', + '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': 'Stop engine', + 'platform': 'volvo', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'engine_stop', + 'unique_id': 'yv1abcdefg1234567_engine_stop', + 'unit_of_measurement': None, + }) +# --- +# name: test_button[xc90_petrol_2019][button.volvo_xc90_stop_engine-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Volvo XC90 Stop engine', + }), + 'context': , + 'entity_id': 'button.volvo_xc90_stop_engine', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/xbox/fixtures/smartglass_console_status.json b/tests/components/xbox/fixtures/smartglass_console_status.json index be732c8b54f40c..ca1db5b910bfba 100644 --- a/tests/components/xbox/fixtures/smartglass_console_status.json +++ b/tests/components/xbox/fixtures/smartglass_console_status.json @@ -3,7 +3,7 @@ "errorCode": "OK", "errorMessage": null }, - "powerState": "ConnectedStandby", + "powerState": "On", "playbackState": "Stopped", "loginState": null, "focusAppAumid": "4DF9E0F8.Netflix_mcm4njqhnhss8!App", diff --git a/tests/components/xbox/snapshots/test_media_player.ambr b/tests/components/xbox/snapshots/test_media_player.ambr new file mode 100644 index 00000000000000..2bcf5bc54694c4 --- /dev/null +++ b/tests/components/xbox/snapshots/test_media_player.ambr @@ -0,0 +1,109 @@ +# serializer version: 1 +# name: test_media_players[media_player.xone-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.xone', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'XONE', + 'platform': 'xbox', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'HIJKLMN', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_players[media_player.xone-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'http://store-images.s-microsoft.com/image/apps.9815.9007199266246365.7dc5d343-fe4a-40c3-93dd-c78e77f97331.45eebdef-f725-4799-bbf8-9ad8391a8279', + 'entity_picture_local': '/api/media_player_proxy/media_player.xone?token=mock_token&cache=739260d7bff66329', + 'friendly_name': 'XONE', + 'media_content_type': , + 'media_title': 'Netflix', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.xone', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_media_players[media_player.xonex-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'media_player', + 'entity_category': None, + 'entity_id': 'media_player.xonex', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'XONEX', + 'platform': 'xbox', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': 'ABCDEFG', + 'unit_of_measurement': None, + }) +# --- +# name: test_media_players[media_player.xonex-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'entity_picture': 'http://store-images.s-microsoft.com/image/apps.9815.9007199266246365.7dc5d343-fe4a-40c3-93dd-c78e77f97331.45eebdef-f725-4799-bbf8-9ad8391a8279', + 'entity_picture_local': '/api/media_player_proxy/media_player.xonex?token=mock_token&cache=739260d7bff66329', + 'friendly_name': 'XONEX', + 'media_content_type': , + 'media_title': 'Netflix', + 'supported_features': , + }), + 'context': , + 'entity_id': 'media_player.xonex', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/xbox/snapshots/test_remote.ambr b/tests/components/xbox/snapshots/test_remote.ambr new file mode 100644 index 00000000000000..da215bbf0016a5 --- /dev/null +++ b/tests/components/xbox/snapshots/test_remote.ambr @@ -0,0 +1,99 @@ +# serializer version: 1 +# name: test_remotes[remote.xone_remote-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'remote', + 'entity_category': None, + 'entity_id': 'remote.xone_remote', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'XONE Remote', + 'platform': 'xbox', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'HIJKLMN', + 'unit_of_measurement': None, + }) +# --- +# name: test_remotes[remote.xone_remote-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'XONE Remote', + 'supported_features': , + }), + 'context': , + 'entity_id': 'remote.xone_remote', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_remotes[remote.xonex_remote-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'remote', + 'entity_category': None, + 'entity_id': 'remote.xonex_remote', + 'has_entity_name': False, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'XONEX Remote', + 'platform': 'xbox', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'ABCDEFG', + 'unit_of_measurement': None, + }) +# --- +# name: test_remotes[remote.xonex_remote-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'XONEX Remote', + 'supported_features': , + }), + 'context': , + 'entity_id': 'remote.xonex_remote', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/xbox/test_media_player.py b/tests/components/xbox/test_media_player.py new file mode 100644 index 00000000000000..cb80ea797905c6 --- /dev/null +++ b/tests/components/xbox/test_media_player.py @@ -0,0 +1,50 @@ +"""Test the Xbox media_player platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +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 +from tests.typing import MagicMock + + +@pytest.fixture(autouse=True) +def media_player_only() -> Generator[None]: + """Enable only the media_player platform.""" + with patch( + "homeassistant.components.xbox.PLATFORMS", + [Platform.MEDIA_PLAYER], + ): + yield + + +@pytest.fixture(autouse=True) +def mock_token() -> Generator[MagicMock]: + """Mock token generator.""" + with patch("secrets.token_hex", return_value="mock_token") as token: + yield token + + +@pytest.mark.usefixtures("xbox_live_client") +async def test_media_players( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the Xbox media player 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) diff --git a/tests/components/xbox/test_remote.py b/tests/components/xbox/test_remote.py new file mode 100644 index 00000000000000..1c9fcbfb31ada9 --- /dev/null +++ b/tests/components/xbox/test_remote.py @@ -0,0 +1,42 @@ +"""Test the Xbox remote platform.""" + +from collections.abc import Generator +from unittest.mock import patch + +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.config_entries import ConfigEntryState +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 remote_only() -> Generator[None]: + """Enable only the remote platform.""" + with patch( + "homeassistant.components.xbox.PLATFORMS", + [Platform.REMOTE], + ): + yield + + +@pytest.mark.usefixtures("xbox_live_client") +async def test_remotes( + hass: HomeAssistant, + config_entry: MockConfigEntry, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test setup of the Xbox remote 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)