From af3bbdee822d8853beaf5d4207fea25c05a3f72e Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 May 2023 22:15:02 +0200 Subject: [PATCH 01/15] Plugwise add value_fn for switch (#93345) * Plugwise add value_fn for switch * Skip none as suggested --- homeassistant/components/plugwise/switch.py | 43 ++++++++++++++++----- 1 file changed, 34 insertions(+), 9 deletions(-) diff --git a/homeassistant/components/plugwise/switch.py b/homeassistant/components/plugwise/switch.py index d6f8a420cc..4204ab5a4d 100644 --- a/homeassistant/components/plugwise/switch.py +++ b/homeassistant/components/plugwise/switch.py @@ -1,8 +1,12 @@ """Plugwise Switch component for HomeAssistant.""" from __future__ import annotations +from collections.abc import Callable +from dataclasses import dataclass from typing import Any +from plugwise import SmileSwitches + from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, @@ -18,29 +22,46 @@ from .coordinator import PlugwiseDataUpdateCoordinator from .entity import PlugwiseEntity from .util import plugwise_command -SWITCHES: tuple[SwitchEntityDescription, ...] = ( - SwitchEntityDescription( + +@dataclass +class PlugwiseSwitchBaseMixin: + """Mixin for required Plugwise switch description keys.""" + + value_fn: Callable[[SmileSwitches], bool] + + +@dataclass +class PlugwiseSwitchEntityDescription(SwitchEntityDescription, PlugwiseSwitchBaseMixin): + """Describes Plugwise switch entity.""" + + +SWITCHES: tuple[PlugwiseSwitchEntityDescription, ...] = ( + PlugwiseSwitchEntityDescription( key="dhw_cm_switch", translation_key="dhw_cm_switch", icon="mdi:water-plus", entity_category=EntityCategory.CONFIG, + value_fn=lambda data: data["dhw_cm_switch"], ), - SwitchEntityDescription( + PlugwiseSwitchEntityDescription( key="lock", translation_key="lock", icon="mdi:lock", entity_category=EntityCategory.CONFIG, + value_fn=lambda data: data["lock"], ), - SwitchEntityDescription( + PlugwiseSwitchEntityDescription( key="relay", translation_key="relay", device_class=SwitchDeviceClass.SWITCH, + value_fn=lambda data: data["relay"], ), - SwitchEntityDescription( + PlugwiseSwitchEntityDescription( key="cooling_ena_switch", name="Cooling", icon="mdi:snowflake-thermometer", entity_category=EntityCategory.CONFIG, + value_fn=lambda data: data["cooling_ena_switch"], ), ) @@ -54,8 +75,10 @@ async def async_setup_entry( coordinator = hass.data[DOMAIN][config_entry.entry_id] entities: list[PlugwiseSwitchEntity] = [] for device_id, device in coordinator.data.devices.items(): + if not (switches := device.get("switches")): + continue for description in SWITCHES: - if "switches" not in device or description.key not in device["switches"]: + if description.key not in switches: continue entities.append(PlugwiseSwitchEntity(coordinator, device_id, description)) async_add_entities(entities) @@ -64,11 +87,13 @@ async def async_setup_entry( class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity): """Representation of a Plugwise plug.""" + entity_description: PlugwiseSwitchEntityDescription + def __init__( self, coordinator: PlugwiseDataUpdateCoordinator, device_id: str, - description: SwitchEntityDescription, + description: PlugwiseSwitchEntityDescription, ) -> None: """Set up the Plugwise API.""" super().__init__(coordinator, device_id) @@ -76,9 +101,9 @@ class PlugwiseSwitchEntity(PlugwiseEntity, SwitchEntity): self._attr_unique_id = f"{device_id}-{description.key}" @property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return True if entity is on.""" - return self.device["switches"].get(self.entity_description.key) + return self.entity_description.value_fn(self.device["switches"]) @plugwise_command async def async_turn_on(self, **kwargs: Any) -> None: From 400263cf0477a8eefd50699d93b08f9c30eac5ec Mon Sep 17 00:00:00 2001 From: Tom Date: Mon, 22 May 2023 23:02:42 +0200 Subject: [PATCH 02/15] Plugwise binary_sensor typing improvement (#93371) Typing miss correction (spotted by epenet) --- homeassistant/components/plugwise/binary_sensor.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/homeassistant/components/plugwise/binary_sensor.py b/homeassistant/components/plugwise/binary_sensor.py index ac7a4f796a..956dd7f36d 100644 --- a/homeassistant/components/plugwise/binary_sensor.py +++ b/homeassistant/components/plugwise/binary_sensor.py @@ -152,7 +152,7 @@ class PlugwiseBinarySensorEntity(PlugwiseEntity, BinarySensorEntity): self._attr_unique_id = f"{device_id}-{description.key}" @property - def is_on(self) -> bool | None: + def is_on(self) -> bool: """Return true if the binary sensor is on.""" return self.entity_description.value_fn(self.device["binary_sensors"]) From 657d285e8fe346908a1eb618dd4d987bb3190236 Mon Sep 17 00:00:00 2001 From: Dominik Date: Mon, 22 May 2023 23:15:01 +0200 Subject: [PATCH 03/15] Bump glances_api to 0.4.2 (#93352) --- homeassistant/components/glances/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/glances/manifest.json b/homeassistant/components/glances/manifest.json index b59fc390a6..767a27ffdf 100644 --- a/homeassistant/components/glances/manifest.json +++ b/homeassistant/components/glances/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/glances", "iot_class": "local_polling", "loggers": ["glances_api"], - "requirements": ["glances_api==0.4.1"] + "requirements": ["glances_api==0.4.2"] } diff --git a/requirements_all.txt b/requirements_all.txt index c685edaa5b..785398bdf4 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -799,7 +799,7 @@ gios==3.1.0 gitterpy==0.1.7 # homeassistant.components.glances -glances_api==0.4.1 +glances_api==0.4.2 # homeassistant.components.goalzero goalzero==0.2.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8719e90849..7370d2b45f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -621,7 +621,7 @@ getmac==0.8.2 gios==3.1.0 # homeassistant.components.glances -glances_api==0.4.1 +glances_api==0.4.2 # homeassistant.components.goalzero goalzero==0.2.1 From b754f03eb1339b64be61c510f002980e57ebb444 Mon Sep 17 00:00:00 2001 From: Erik Montnemery Date: Tue, 23 May 2023 10:44:02 +0200 Subject: [PATCH 04/15] Add WS command for getting a config entry (#93387) * Add WS command for getting a config entry * Update tests --- .../components/config/config_entries.py | 29 +++++- .../components/bluetooth/test_config_flow.py | 6 +- .../components/config/test_config_entries.py | 95 ++++++++++++------- tests/components/shelly/test_config_flow.py | 6 +- 4 files changed, 95 insertions(+), 41 deletions(-) diff --git a/homeassistant/components/config/config_entries.py b/homeassistant/components/config/config_entries.py index c6fd400315..2e1ea72dfb 100644 --- a/homeassistant/components/config/config_entries.py +++ b/homeassistant/components/config/config_entries.py @@ -41,8 +41,9 @@ async def async_setup(hass): hass.http.register_view(OptionManagerFlowIndexView(hass.config_entries.options)) hass.http.register_view(OptionManagerFlowResourceView(hass.config_entries.options)) - websocket_api.async_register_command(hass, config_entries_get) + websocket_api.async_register_command(hass, config_entries_get_matching) websocket_api.async_register_command(hass, config_entry_disable) + websocket_api.async_register_command(hass, config_entry_get) websocket_api.async_register_command(hass, config_entry_update) websocket_api.async_register_command(hass, config_entries_subscribe) websocket_api.async_register_command(hass, config_entries_progress) @@ -284,6 +285,28 @@ def get_entry( return entry +@websocket_api.require_admin +@websocket_api.websocket_command( + { + "type": "config_entries/get", + "entry_id": str, + } +) +@websocket_api.async_response +async def config_entry_get( + hass: HomeAssistant, + connection: websocket_api.ActiveConnection, + msg: dict[str, Any], +) -> None: + """Update config entry.""" + entry = get_entry(hass, connection, msg["entry_id"], msg["id"]) + if entry is None: + return + + result = {"config_entry": entry_json(entry)} + connection.send_result(msg["id"], result) + + @websocket_api.require_admin @websocket_api.websocket_command( { @@ -409,13 +432,13 @@ async def ignore_config_flow( @websocket_api.websocket_command( { - vol.Required("type"): "config_entries/get", + vol.Required("type"): "config_entries/get_matching", vol.Optional("type_filter"): vol.All(cv.ensure_list, [str]), vol.Optional("domain"): str, } ) @websocket_api.async_response -async def config_entries_get( +async def config_entries_get_matching( hass: HomeAssistant, connection: websocket_api.ActiveConnection, msg: dict[str, Any], diff --git a/tests/components/bluetooth/test_config_flow.py b/tests/components/bluetooth/test_config_flow.py index 1489f349cf..16fe9602d3 100644 --- a/tests/components/bluetooth/test_config_flow.py +++ b/tests/components/bluetooth/test_config_flow.py @@ -36,7 +36,7 @@ async def test_options_flow_disabled_not_setup( await ws_client.send_json( { "id": 5, - "type": "config_entries/get", + "type": "config_entries/get_matching", "domain": "bluetooth", } ) @@ -370,7 +370,7 @@ async def test_options_flow_disabled_macos( await ws_client.send_json( { "id": 5, - "type": "config_entries/get", + "type": "config_entries/get_matching", "domain": "bluetooth", } ) @@ -403,7 +403,7 @@ async def test_options_flow_enabled_linux( await ws_client.send_json( { "id": 5, - "type": "config_entries/get", + "type": "config_entries/get_matching", "domain": "bluetooth", } ) diff --git a/tests/components/config/test_config_entries.py b/tests/components/config/test_config_entries.py index f861d887b9..d54abe7727 100644 --- a/tests/components/config/test_config_entries.py +++ b/tests/components/config/test_config_entries.py @@ -965,6 +965,55 @@ async def test_options_flow_with_invalid_data(hass: HomeAssistant, client) -> No } +async def test_get(hass: HomeAssistant, hass_ws_client: WebSocketGenerator) -> None: + """Test that we can get a config entry.""" + assert await async_setup_component(hass, "config", {}) + ws_client = await hass_ws_client(hass) + + entry = MockConfigEntry(domain="demo", state=core_ce.ConfigEntryState.LOADED) + entry.add_to_hass(hass) + + assert entry.pref_disable_new_entities is False + assert entry.pref_disable_polling is False + + await ws_client.send_json_auto_id( + { + "type": "config_entries/get", + "entry_id": entry.entry_id, + } + ) + response = await ws_client.receive_json() + + assert response["success"] + assert response["result"]["config_entry"] == { + "disabled_by": None, + "domain": "demo", + "entry_id": entry.entry_id, + "pref_disable_new_entities": False, + "pref_disable_polling": False, + "reason": None, + "source": "user", + "state": "loaded", + "supports_options": False, + "supports_remove_device": False, + "supports_unload": False, + "title": "Mock Title", + } + + await ws_client.send_json_auto_id( + { + "type": "config_entries/get", + "entry_id": "blah", + } + ) + response = await ws_client.receive_json() + assert not response["success"] + assert response["error"] == { + "code": "not_found", + "message": "Config entry not found", + } + + async def test_update_prefrences( hass: HomeAssistant, hass_ws_client: WebSocketGenerator ) -> None: @@ -1209,7 +1258,7 @@ async def test_ignore_flow_nonexisting( assert response["error"]["code"] == "not_found" -async def test_get_entries_ws( +async def test_get_matching_entries_ws( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, clear_handlers ) -> None: """Test get entries with the websocket api.""" @@ -1260,14 +1309,8 @@ async def test_get_entries_ws( ws_client = await hass_ws_client(hass) - await ws_client.send_json( - { - "id": 5, - "type": "config_entries/get", - } - ) + await ws_client.send_json_auto_id({"type": "config_entries/get_matching"}) response = await ws_client.receive_json() - assert response["id"] == 5 assert response["result"] == [ { "disabled_by": None, @@ -1341,16 +1384,14 @@ async def test_get_entries_ws( }, ] - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 6, - "type": "config_entries/get", + "type": "config_entries/get_matching", "domain": "comp1", "type_filter": "hub", } ) response = await ws_client.receive_json() - assert response["id"] == 6 assert response["result"] == [ { "disabled_by": None, @@ -1368,15 +1409,13 @@ async def test_get_entries_ws( } ] - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 7, - "type": "config_entries/get", + "type": "config_entries/get_matching", "type_filter": ["service", "device"], } ) response = await ws_client.receive_json() - assert response["id"] == 7 assert response["result"] == [ { "disabled_by": None, @@ -1408,15 +1447,13 @@ async def test_get_entries_ws( }, ] - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 8, - "type": "config_entries/get", + "type": "config_entries/get_matching", "type_filter": "hub", } ) response = await ws_client.receive_json() - assert response["id"] == 8 assert response["result"] == [ { "disabled_by": None, @@ -1453,16 +1490,14 @@ async def test_get_entries_ws( "homeassistant.components.config.config_entries.async_get_integrations", return_value={"any": IntegrationNotFound("any")}, ): - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 9, - "type": "config_entries/get", + "type": "config_entries/get_matching", "type_filter": "hub", } ) response = await ws_client.receive_json() - assert response["id"] == 9 assert response["result"] == [ { "disabled_by": None, @@ -1541,16 +1576,14 @@ async def test_get_entries_ws( "homeassistant.components.config.config_entries.async_get_integrations", return_value={"any": IntegrationNotFound("any")}, ): - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 10, - "type": "config_entries/get", + "type": "config_entries/get_matching", "type_filter": ["helper"], } ) response = await ws_client.receive_json() - assert response["id"] == 10 assert response["result"] == [] # Verify we raise if something really goes wrong @@ -1559,16 +1592,14 @@ async def test_get_entries_ws( "homeassistant.components.config.config_entries.async_get_integrations", return_value={"any": Exception()}, ): - await ws_client.send_json( + await ws_client.send_json_auto_id( { - "id": 11, - "type": "config_entries/get", + "type": "config_entries/get_matching", "type_filter": ["device", "hub", "service"], } ) response = await ws_client.receive_json() - assert response["id"] == 11 assert response["success"] is False diff --git a/tests/components/shelly/test_config_flow.py b/tests/components/shelly/test_config_flow.py index 621885bef0..210db65dfd 100644 --- a/tests/components/shelly/test_config_flow.py +++ b/tests/components/shelly/test_config_flow.py @@ -858,7 +858,7 @@ async def test_options_flow_disabled_gen_1( await ws_client.send_json( { "id": 5, - "type": "config_entries/get", + "type": "config_entries/get_matching", "domain": "shelly", } ) @@ -879,7 +879,7 @@ async def test_options_flow_enabled_gen_2( await ws_client.send_json( { "id": 5, - "type": "config_entries/get", + "type": "config_entries/get_matching", "domain": "shelly", } ) @@ -900,7 +900,7 @@ async def test_options_flow_disabled_sleepy_gen_2( await ws_client.send_json( { "id": 5, - "type": "config_entries/get", + "type": "config_entries/get_matching", "domain": "shelly", } ) From e100bcfaeae6a266a1d014d9ba374fa1d9690ffa Mon Sep 17 00:00:00 2001 From: Diogo Gomes Date: Tue, 23 May 2023 10:19:29 +0100 Subject: [PATCH 05/15] Better handling of source sensor unavailability in Riemman Integration (#93137) * refactor and increase coverage * fix log order --- .../components/integration/sensor.py | 70 +++++++++----- tests/components/integration/test_sensor.py | 91 +++++++++++++++++-- 2 files changed, 126 insertions(+), 35 deletions(-) diff --git a/homeassistant/components/integration/sensor.py b/homeassistant/components/integration/sensor.py index d55a113664..64d83506ad 100644 --- a/homeassistant/components/integration/sensor.py +++ b/homeassistant/components/integration/sensor.py @@ -197,25 +197,23 @@ class IntegrationSensor(RestoreEntity, SensorEntity): old_state: State | None = event.data.get("old_state") new_state: State | None = event.data.get("new_state") - if ( - source_state := self.hass.states.get(self._sensor_source_id) - ) is None or source_state.state == STATE_UNAVAILABLE: - self._attr_available = False - self.async_write_ha_state() - return - - self._attr_available = True - - if new_state is None or new_state.state in ( - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ): - return - # We may want to update our state before an early return, # based on the source sensor's unit_of_measurement # or device_class. update_state = False + + if ( + source_state := self.hass.states.get(self._sensor_source_id) + ) is None or source_state.state == STATE_UNAVAILABLE: + self._attr_available = False + update_state = True + else: + self._attr_available = True + + if old_state is None or new_state is None: + # we can't calculate the elapsed time, so we can't calculate the integral + return + unit = new_state.attributes.get(ATTR_UNIT_OF_MEASUREMENT) if unit is not None: new_unit_of_measurement = self._unit(unit) @@ -235,31 +233,53 @@ class IntegrationSensor(RestoreEntity, SensorEntity): if update_state: self.async_write_ha_state() - if old_state is None or old_state.state in ( - STATE_UNKNOWN, - STATE_UNAVAILABLE, - ): - return - try: # integration as the Riemann integral of previous measures. - area = Decimal(0) elapsed_time = ( new_state.last_updated - old_state.last_updated ).total_seconds() - if self._method == METHOD_TRAPEZOIDAL: + if ( + self._method == METHOD_TRAPEZOIDAL + and new_state.state + not in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ) + and old_state.state + not in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ) + ): area = ( (Decimal(new_state.state) + Decimal(old_state.state)) * Decimal(elapsed_time) / 2 ) - elif self._method == METHOD_LEFT: + elif self._method == METHOD_LEFT and old_state.state not in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ): area = Decimal(old_state.state) * Decimal(elapsed_time) - elif self._method == METHOD_RIGHT: + elif self._method == METHOD_RIGHT and new_state.state not in ( + STATE_UNKNOWN, + STATE_UNAVAILABLE, + ): area = Decimal(new_state.state) * Decimal(elapsed_time) + else: + _LOGGER.debug( + "Could not apply method %s to %s -> %s", + self._method, + old_state.state, + new_state.state, + ) + return integral = area / (self._unit_prefix * self._unit_time) + _LOGGER.debug( + "area = %s, integral = %s state = %s", area, integral, self._state + ) assert isinstance(integral, Decimal) except ValueError as err: _LOGGER.warning("While calculating integration: %s", err) diff --git a/tests/components/integration/test_sensor.py b/tests/components/integration/test_sensor.py index 93da55c51a..b2ad0b36b6 100644 --- a/tests/components/integration/test_sensor.py +++ b/tests/components/integration/test_sensor.py @@ -2,6 +2,8 @@ from datetime import timedelta from unittest.mock import patch +import pytest + from homeassistant.components.sensor import SensorDeviceClass, SensorStateClass from homeassistant.const import ( ATTR_UNIT_OF_MEASUREMENT, @@ -20,7 +22,8 @@ import homeassistant.util.dt as dt_util from tests.common import mock_restore_cache -async def test_state(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("method", ["trapezoidal", "left", "right"]) +async def test_state(hass: HomeAssistant, method) -> None: """Test integration sensor state.""" config = { "sensor": { @@ -28,6 +31,7 @@ async def test_state(hass: HomeAssistant) -> None: "name": "integration", "source": "sensor.power", "round": 2, + "method": method, } } @@ -46,8 +50,8 @@ async def test_state(hass: HomeAssistant) -> None: assert state.attributes.get("state_class") is SensorStateClass.TOTAL assert "device_class" not in state.attributes - future_now = dt_util.utcnow() + timedelta(seconds=3600) - with patch("homeassistant.util.dt.utcnow", return_value=future_now): + now += timedelta(seconds=3600) + with patch("homeassistant.util.dt.utcnow", return_value=now): hass.states.async_set( entity_id, 1, @@ -69,6 +73,62 @@ async def test_state(hass: HomeAssistant) -> None: assert state.attributes.get("device_class") == SensorDeviceClass.ENERGY assert state.attributes.get("state_class") is SensorStateClass.TOTAL + # 1 hour after last update, power sensor is unavailable + now += timedelta(seconds=3600) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + entity_id, + STATE_UNAVAILABLE, + { + "device_class": SensorDeviceClass.POWER, + ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, + }, + force_update=True, + ) + await hass.async_block_till_done() + + state = hass.states.get("sensor.integration") + assert state.state == STATE_UNAVAILABLE + + # 1 hour after last update, power sensor is back to normal at 2 KiloWatts and stays for 1 hour += 2kWh + now += timedelta(seconds=3600) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + entity_id, + 2, + { + "device_class": SensorDeviceClass.POWER, + ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, + }, + force_update=True, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert ( + round(float(state.state), config["sensor"]["round"]) == 3.0 + if method == "right" + else 1.0 + ) + + now += timedelta(seconds=3600) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set( + entity_id, + 2, + { + "device_class": SensorDeviceClass.POWER, + ATTR_UNIT_OF_MEASUREMENT: UnitOfPower.KILO_WATT, + }, + force_update=True, + ) + await hass.async_block_till_done() + state = hass.states.get("sensor.integration") + assert ( + round(float(state.state), config["sensor"]["round"]) == 5.0 + if method == "right" + else 3.0 + ) + async def test_restore_state(hass: HomeAssistant) -> None: """Test integration sensor state is restored correctly.""" @@ -416,13 +476,15 @@ async def test_units(hass: HomeAssistant) -> None: assert new_state.state == STATE_UNAVAILABLE -async def test_device_class(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("method", ["trapezoidal", "left", "right"]) +async def test_device_class(hass: HomeAssistant, method) -> None: """Test integration sensor units using a power source.""" config = { "sensor": { "platform": "integration", "name": "integration", "source": "sensor.power", + "method": method, } } @@ -465,13 +527,15 @@ async def test_device_class(hass: HomeAssistant) -> None: assert state.attributes.get("device_class") == SensorDeviceClass.ENERGY -async def test_calc_errors(hass: HomeAssistant) -> None: +@pytest.mark.parametrize("method", ["trapezoidal", "left", "right"]) +async def test_calc_errors(hass: HomeAssistant, method) -> None: """Test integration sensor units using a power source.""" config = { "sensor": { "platform": "integration", "name": "integration", "source": "sensor.power", + "method": method, } } @@ -479,6 +543,7 @@ async def test_calc_errors(hass: HomeAssistant) -> None: entity_id = config["sensor"]["source"] + now = dt_util.utcnow() hass.states.async_set(entity_id, None, {}) await hass.async_block_till_done() @@ -489,19 +554,25 @@ async def test_calc_errors(hass: HomeAssistant) -> None: assert state.state == STATE_UNKNOWN # Moving from an unknown state to a value is a calc error and should - # not change the value of the Reimann sensor. - hass.states.async_set(entity_id, 0, {"device_class": None}) + # not change the value of the Reimann sensor, unless the method used is "right". + now += timedelta(seconds=3600) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set(entity_id, 0, {"device_class": None}) + await hass.async_block_till_done() await hass.async_block_till_done() state = hass.states.get("sensor.integration") assert state is not None - assert state.state == STATE_UNKNOWN + assert state.state == STATE_UNKNOWN if method != "right" else "0.000" # With the source sensor updated successfully, the Reimann sensor # should have a zero (known) value. - hass.states.async_set(entity_id, 1, {"device_class": None}) + now += timedelta(seconds=3600) + with patch("homeassistant.util.dt.utcnow", return_value=now): + hass.states.async_set(entity_id, 1, {"device_class": None}) + await hass.async_block_till_done() await hass.async_block_till_done() state = hass.states.get("sensor.integration") assert state is not None - assert round(float(state.state)) == 0 + assert round(float(state.state)) == 0 if method != "right" else 1 From 86ad5ad1137783c9b935296ddfbac773db8ab74b Mon Sep 17 00:00:00 2001 From: luar123 <49960470+luar123@users.noreply.github.com> Date: Tue, 23 May 2023 11:21:50 +0200 Subject: [PATCH 06/15] Change snapcast to local_push (#93113) --- homeassistant/components/snapcast/manifest.json | 2 +- homeassistant/generated/integrations.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/snapcast/manifest.json b/homeassistant/components/snapcast/manifest.json index 13091689fe..f59283bb5f 100644 --- a/homeassistant/components/snapcast/manifest.json +++ b/homeassistant/components/snapcast/manifest.json @@ -4,7 +4,7 @@ "codeowners": ["@luar123"], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/snapcast", - "iot_class": "local_polling", + "iot_class": "local_push", "loggers": ["construct", "snapcast"], "requirements": ["snapcast==2.3.3"] } diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index c69ac70e7d..c9cd79e60d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5097,7 +5097,7 @@ "name": "Snapcast", "integration_type": "hub", "config_flow": true, - "iot_class": "local_polling" + "iot_class": "local_push" }, "snips": { "name": "Snips", From 7e1a946705e79c9e3c3585b5fb0643d549592d4e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=81lvaro=20Fern=C3=A1ndez=20Rojas?= Date: Tue, 23 May 2023 11:23:32 +0200 Subject: [PATCH 07/15] Support Airzone temperature ranges (HEAT_COOL) (#93110) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * airzone: climate: add Temperature range support This is useful for HEAT_COOL climate mode (Airzone AUTO). Signed-off-by: Álvaro Fernández Rojas * trigger CI --------- Signed-off-by: Álvaro Fernández Rojas --- homeassistant/components/airzone/climate.py | 27 ++++++++++++-- tests/components/airzone/test_climate.py | 40 +++++++++++++++++++++ 2 files changed, 64 insertions(+), 3 deletions(-) diff --git a/homeassistant/components/airzone/climate.py b/homeassistant/components/airzone/climate.py index 764beddd43..fda007cdc8 100644 --- a/homeassistant/components/airzone/climate.py +++ b/homeassistant/components/airzone/climate.py @@ -5,11 +5,16 @@ from typing import Any, Final from aioairzone.common import OperationAction, OperationMode from aioairzone.const import ( + API_COOL_SET_POINT, + API_HEAT_SET_POINT, API_MODE, API_ON, API_SET_POINT, API_SPEED, AZD_ACTION, + AZD_COOL_TEMP_SET, + AZD_DOUBLE_SET_POINT, + AZD_HEAT_TEMP_SET, AZD_HUMIDITY, AZD_MASTER, AZD_MODE, @@ -27,6 +32,8 @@ from aioairzone.const import ( ) from homeassistant.components.climate import ( + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, FAN_AUTO, FAN_HIGH, FAN_LOW, @@ -137,6 +144,10 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): and self.get_airzone_value(AZD_SPEEDS) is not None ): self._set_fan_speeds() + if self.get_airzone_value(AZD_DOUBLE_SET_POINT): + self._attr_supported_features |= ( + ClimateEntityFeature.TARGET_TEMPERATURE_RANGE + ) self._async_update_attrs() @@ -201,9 +212,12 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - params = { - API_SET_POINT: kwargs.get(ATTR_TEMPERATURE), - } + params = {} + if ATTR_TEMPERATURE in kwargs: + params[API_SET_POINT] = kwargs[ATTR_TEMPERATURE] + if ATTR_TARGET_TEMP_LOW in kwargs and ATTR_TARGET_TEMP_HIGH in kwargs: + params[API_COOL_SET_POINT] = kwargs[ATTR_TARGET_TEMP_LOW] + params[API_HEAT_SET_POINT] = kwargs[ATTR_TARGET_TEMP_HIGH] await self._async_update_hvac_params(params) @callback @@ -229,3 +243,10 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity): self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET) if self.supported_features & ClimateEntityFeature.FAN_MODE: self._attr_fan_mode = self._speeds.get(self.get_airzone_value(AZD_SPEED)) + if self.supported_features & ClimateEntityFeature.TARGET_TEMPERATURE_RANGE: + self._attr_target_temperature_high = self.get_airzone_value( + AZD_HEAT_TEMP_SET + ) + self._attr_target_temperature_low = self.get_airzone_value( + AZD_COOL_TEMP_SET + ) diff --git a/tests/components/airzone/test_climate.py b/tests/components/airzone/test_climate.py index cfbf6049e7..0baac5c9d7 100644 --- a/tests/components/airzone/test_climate.py +++ b/tests/components/airzone/test_climate.py @@ -3,7 +3,9 @@ from unittest.mock import patch from aioairzone.common import OperationMode from aioairzone.const import ( + API_COOL_SET_POINT, API_DATA, + API_HEAT_SET_POINT, API_MODE, API_ON, API_SET_POINT, @@ -25,6 +27,8 @@ from homeassistant.components.climate import ( ATTR_HVAC_MODES, ATTR_MAX_TEMP, ATTR_MIN_TEMP, + ATTR_TARGET_TEMP_HIGH, + ATTR_TARGET_TEMP_LOW, ATTR_TARGET_TEMP_STEP, DOMAIN as CLIMATE_DOMAIN, FAN_AUTO, @@ -494,3 +498,39 @@ async def test_airzone_climate_set_temp_error(hass: HomeAssistant) -> None: state = hass.states.get("climate.dorm_2") assert state.attributes.get(ATTR_TEMPERATURE) == 19.5 + + +async def test_airzone_climate_set_temp_range(hass: HomeAssistant) -> None: + """Test setting the target temperature range.""" + + HVAC_MOCK = { + API_DATA: [ + { + API_SYSTEM_ID: 3, + API_ZONE_ID: 1, + API_COOL_SET_POINT: 68.0, + API_HEAT_SET_POINT: 77.0, + } + ] + } + + await async_init_integration(hass) + + with patch( + "homeassistant.components.airzone.AirzoneLocalApi.put_hvac", + return_value=HVAC_MOCK, + ): + await hass.services.async_call( + CLIMATE_DOMAIN, + SERVICE_SET_TEMPERATURE, + { + ATTR_ENTITY_ID: "climate.dkn_plus", + ATTR_TARGET_TEMP_HIGH: 25.0, + ATTR_TARGET_TEMP_LOW: 20.0, + }, + blocking=True, + ) + + state = hass.states.get("climate.dkn_plus") + assert state.attributes.get(ATTR_TARGET_TEMP_HIGH) == 25.0 + assert state.attributes.get(ATTR_TARGET_TEMP_LOW) == 20.0 From d0d74eb30edc38501d34f850414bbc38e79bc120 Mon Sep 17 00:00:00 2001 From: Paul Frank Date: Tue, 23 May 2023 11:28:33 +0200 Subject: [PATCH 08/15] Bump mystrom to 2.2.0 (#93389) --- homeassistant/components/mystrom/manifest.json | 2 +- requirements_all.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/components/mystrom/manifest.json b/homeassistant/components/mystrom/manifest.json index d6f551451d..7659b1d802 100644 --- a/homeassistant/components/mystrom/manifest.json +++ b/homeassistant/components/mystrom/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/mystrom", "iot_class": "local_polling", "loggers": ["pymystrom"], - "requirements": ["python-mystrom==1.1.2"] + "requirements": ["python-mystrom==2.2.0"] } diff --git a/requirements_all.txt b/requirements_all.txt index 785398bdf4..414aff976a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2095,7 +2095,7 @@ python-miio==0.5.12 python-mpd2==3.0.5 # homeassistant.components.mystrom -python-mystrom==1.1.2 +python-mystrom==2.2.0 # homeassistant.components.nest python-nest==4.2.0 From 1dccb8a9a9486b372a7f576f66c5081064cd2747 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 23 May 2023 11:33:51 +0200 Subject: [PATCH 09/15] Cleanup deprecated YAML from samsungtv (#93392) --- .../components/samsungtv/__init__.py | 75 +-- .../components/samsungtv/config_flow.py | 23 - homeassistant/components/samsungtv/const.py | 2 - .../components/samsungtv/media_player.py | 24 +- tests/components/samsungtv/__init__.py | 3 +- tests/components/samsungtv/conftest.py | 9 - .../samsungtv/snapshots/test_init.ambr | 10 - .../components/samsungtv/test_config_flow.py | 490 +++++------------- tests/components/samsungtv/test_init.py | 86 +-- .../components/samsungtv/test_media_player.py | 214 +++----- 10 files changed, 226 insertions(+), 710 deletions(-) delete mode 100644 tests/components/samsungtv/snapshots/test_init.ambr diff --git a/homeassistant/components/samsungtv/__init__.py b/homeassistant/components/samsungtv/__init__.py index 55d0fbdfbd..a5deb3ca62 100644 --- a/homeassistant/components/samsungtv/__init__.py +++ b/homeassistant/components/samsungtv/__init__.py @@ -3,14 +3,11 @@ from __future__ import annotations from collections.abc import Coroutine, Mapping from functools import partial -import socket from typing import Any from urllib.parse import urlparse import getmac -import voluptuous as vol -from homeassistant import config_entries from homeassistant.components import ssdp from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( @@ -18,7 +15,6 @@ from homeassistant.const import ( CONF_MAC, CONF_METHOD, CONF_MODEL, - CONF_NAME, CONF_PORT, CONF_TOKEN, EVENT_HOMEASSISTANT_STOP, @@ -30,10 +26,8 @@ from homeassistant.helpers import ( config_validation as cv, device_registry as dr, entity_registry as er, - issue_registry as ir, ) from homeassistant.helpers.debounce import Debouncer -from homeassistant.helpers.typing import ConfigType from .bridge import ( SamsungTVBridge, @@ -42,11 +36,9 @@ from .bridge import ( model_requires_encryption, ) from .const import ( - CONF_ON_ACTION, CONF_SESSION_ID, CONF_SSDP_MAIN_TV_AGENT_LOCATION, CONF_SSDP_RENDERING_CONTROL_LOCATION, - DEFAULT_NAME, DOMAIN, ENTRY_RELOAD_COOLDOWN, LEGACY_PORT, @@ -57,73 +49,9 @@ from .const import ( UPNP_SVC_RENDERING_CONTROL, ) - -def ensure_unique_hosts(value: dict[Any, Any]) -> dict[Any, Any]: - """Validate that all configs have a unique host.""" - vol.Schema(vol.Unique("duplicate host entries found"))( - [entry[CONF_HOST] for entry in value] - ) - return value - - PLATFORMS = [Platform.MEDIA_PLAYER] -CONFIG_SCHEMA = vol.Schema( - { - DOMAIN: vol.All( - cv.ensure_list, - [ - cv.deprecated(CONF_PORT), - vol.Schema( - { - vol.Required(CONF_HOST): cv.string, - vol.Optional(CONF_NAME, default=DEFAULT_NAME): cv.string, - vol.Optional(CONF_PORT): cv.port, - vol.Optional(CONF_ON_ACTION): cv.SCRIPT_SCHEMA, - } - ), - ], - ensure_unique_hosts, - ) - }, - extra=vol.ALLOW_EXTRA, -) - - -async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: - """Set up the Samsung TV integration.""" - hass.data[DOMAIN] = {} - if DOMAIN not in config: - return True - - ir.async_create_issue( - hass, - DOMAIN, - "deprecated_yaml", - breaks_in_ha_version="2023.6.0", - is_fixable=False, - severity=ir.IssueSeverity.WARNING, - translation_key="deprecated_yaml", - translation_placeholders={ - "on_action_url": "https://www.home-assistant.io/integrations/samsungtv/#turn-on-action" - }, - learn_more_url="https://www.home-assistant.io/integrations/samsungtv/#turn-on-action", - ) - for entry_config in config[DOMAIN]: - ip_address = await hass.async_add_executor_job( - socket.gethostbyname, entry_config[CONF_HOST] - ) - hass.data[DOMAIN][ip_address] = { - CONF_ON_ACTION: entry_config.get(CONF_ON_ACTION) - } - hass.async_create_task( - hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=entry_config, - ) - ) - return True +CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False) @callback @@ -195,6 +123,7 @@ async def _async_update_ssdp_locations(hass: HomeAssistant, entry: ConfigEntry) async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up the Samsung TV platform.""" + hass.data.setdefault(DOMAIN, {}) # Initialize bridge if entry.data.get(CONF_METHOD) == METHOD_ENCRYPTED_WEBSOCKET: diff --git a/homeassistant/components/samsungtv/config_flow.py b/homeassistant/components/samsungtv/config_flow.py index bc8cc89584..f98e3667b5 100644 --- a/homeassistant/components/samsungtv/config_flow.py +++ b/homeassistant/components/samsungtv/config_flow.py @@ -35,12 +35,9 @@ from .const import ( CONF_SSDP_RENDERING_CONTROL_LOCATION, DEFAULT_MANUFACTURER, DOMAIN, - ENCRYPTED_WEBSOCKET_PORT, - LEGACY_PORT, LOGGER, METHOD_ENCRYPTED_WEBSOCKET, METHOD_LEGACY, - METHOD_WEBSOCKET, RESULT_AUTH_MISSING, RESULT_CANNOT_CONNECT, RESULT_INVALID_PIN, @@ -50,7 +47,6 @@ from .const import ( SUCCESSFUL_RESULTS, UPNP_SVC_MAIN_TV_AGENT, UPNP_SVC_RENDERING_CONTROL, - WEBSOCKET_PORTS, ) DATA_SCHEMA = vol.Schema({vol.Required(CONF_HOST): str, vol.Required(CONF_NAME): str}) @@ -231,25 +227,6 @@ class SamsungTVConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): self._mac = mac return True - async def async_step_import(self, user_input: dict[str, Any]) -> FlowResult: - """Handle configuration by yaml file.""" - # We need to import even if we cannot validate - # since the TV may be off at startup - await self._async_set_name_host_from_input(user_input) - self._async_abort_entries_match({CONF_HOST: self._host}) - port = user_input.get(CONF_PORT) - if port in WEBSOCKET_PORTS: - user_input[CONF_METHOD] = METHOD_WEBSOCKET - elif port == ENCRYPTED_WEBSOCKET_PORT: - user_input[CONF_METHOD] = METHOD_ENCRYPTED_WEBSOCKET - elif port == LEGACY_PORT: - user_input[CONF_METHOD] = METHOD_LEGACY - user_input[CONF_MANUFACTURER] = DEFAULT_MANUFACTURER - return self.async_create_entry( - title=self._title, - data=user_input, - ) - async def _async_set_name_host_from_input(self, user_input: dict[str, Any]) -> None: try: self._host = await self.hass.async_add_executor_job( diff --git a/homeassistant/components/samsungtv/const.py b/homeassistant/components/samsungtv/const.py index 2585d742be..6699d26243 100644 --- a/homeassistant/components/samsungtv/const.py +++ b/homeassistant/components/samsungtv/const.py @@ -6,7 +6,6 @@ DOMAIN = "samsungtv" ATTR_PROPERTIES = "properties" -DEFAULT_NAME = "Samsung TV" DEFAULT_MANUFACTURER = "Samsung" VALUE_CONF_NAME = "HomeAssistant" @@ -16,7 +15,6 @@ CONF_DESCRIPTION = "description" CONF_MANUFACTURER = "manufacturer" CONF_SSDP_RENDERING_CONTROL_LOCATION = "ssdp_rendering_control_location" CONF_SSDP_MAIN_TV_AGENT_LOCATION = "ssdp_main_tv_agent_location" -CONF_ON_ACTION = "turn_on_action" CONF_SESSION_ID = "session_id" RESULT_AUTH_MISSING = "auth_missing" diff --git a/homeassistant/components/samsungtv/media_player.py b/homeassistant/components/samsungtv/media_player.py index 302d9c4915..d4c04942e6 100644 --- a/homeassistant/components/samsungtv/media_player.py +++ b/homeassistant/components/samsungtv/media_player.py @@ -41,16 +41,13 @@ from homeassistant.helpers import ( from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.entity import DeviceInfo from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.script import Script from homeassistant.helpers.trigger import PluggableAction from homeassistant.util import dt as dt_util from .bridge import SamsungTVBridge, SamsungTVWSBridge from .const import ( CONF_MANUFACTURER, - CONF_ON_ACTION, CONF_SSDP_RENDERING_CONTROL_LOCATION, - DEFAULT_NAME, DOMAIN, LOGGER, ) @@ -86,16 +83,7 @@ async def async_setup_entry( ) -> None: """Set up the Samsung TV from a config entry.""" bridge = hass.data[DOMAIN][entry.entry_id] - - host = entry.data[CONF_HOST] - on_script = None - data = hass.data[DOMAIN] - if turn_on_action := data.get(host, {}).get(CONF_ON_ACTION): - on_script = Script( - hass, turn_on_action, entry.data.get(CONF_NAME, DEFAULT_NAME), DOMAIN - ) - - async_add_entities([SamsungTVDevice(bridge, entry, on_script)], True) + async_add_entities([SamsungTVDevice(bridge, entry)], True) class SamsungTVDevice(MediaPlayerEntity): @@ -107,7 +95,6 @@ class SamsungTVDevice(MediaPlayerEntity): self, bridge: SamsungTVBridge, config_entry: ConfigEntry, - on_script: Script | None, ) -> None: """Initialize the Samsung device.""" self._config_entry = config_entry @@ -117,7 +104,6 @@ class SamsungTVDevice(MediaPlayerEntity): CONF_SSDP_RENDERING_CONTROL_LOCATION ) self._turn_on = PluggableAction(self.async_write_ha_state) - self._on_script = on_script # Assume that the TV is in Play mode self._playing: bool = True @@ -130,8 +116,8 @@ class SamsungTVDevice(MediaPlayerEntity): self._app_list_event: asyncio.Event = asyncio.Event() self._attr_supported_features = SUPPORT_SAMSUNGTV - if self._on_script or self._mac: - # (deprecated) add turn-on if on_script YAML or mac is available + if self._mac: + # (deprecated) add turn-on if mac is available # Triggers have not yet been registered so this is adjusted in the property self._attr_supported_features |= MediaPlayerEntityFeature.TURN_ON if self._ssdp_rendering_control_location: @@ -375,7 +361,6 @@ class SamsungTVDevice(MediaPlayerEntity): return ( self.state == MediaPlayerState.ON or bool(self._turn_on) - or self._on_script is not None or self._mac is not None or self._power_off_in_progress() ) @@ -477,9 +462,6 @@ class SamsungTVDevice(MediaPlayerEntity): """Turn the media player on.""" if self._turn_on: await self._turn_on.async_run(self.hass, self._context) - elif self._on_script: - # YAML on_script is deprecated - replaced by turn_on trigger - await self._on_script.async_run(context=self._context) elif self._mac: await self.hass.async_add_executor_job(self._wake_on_lan) diff --git a/tests/components/samsungtv/__init__.py b/tests/components/samsungtv/__init__.py index 53e47f6170..be28d6132a 100644 --- a/tests/components/samsungtv/__init__.py +++ b/tests/components/samsungtv/__init__.py @@ -7,7 +7,6 @@ from homeassistant.components.samsungtv.const import DOMAIN, ENTRY_RELOAD_COOLDO from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component from homeassistant.util import dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed @@ -29,7 +28,7 @@ async def setup_samsungtv_entry(hass: HomeAssistant, data: ConfigType) -> Config ) entry.add_to_hass(hass) - await async_setup_component(hass, DOMAIN, {}) + await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() return entry diff --git a/tests/components/samsungtv/conftest.py b/tests/components/samsungtv/conftest.py index 1638057464..5e8ab9311a 100644 --- a/tests/components/samsungtv/conftest.py +++ b/tests/components/samsungtv/conftest.py @@ -290,15 +290,6 @@ def remoteencws_fixture() -> Mock: yield remoteencws -@pytest.fixture(name="delay") -def delay_fixture() -> Mock: - """Patch the delay script function.""" - with patch( - "homeassistant.components.samsungtv.media_player.Script.async_run" - ) as delay: - yield delay - - @pytest.fixture def mock_now() -> datetime: """Fixture for dtutil.now.""" diff --git a/tests/components/samsungtv/snapshots/test_init.ambr b/tests/components/samsungtv/snapshots/test_init.ambr deleted file mode 100644 index 877bfe0420..0000000000 --- a/tests/components/samsungtv/snapshots/test_init.ambr +++ /dev/null @@ -1,10 +0,0 @@ -# serializer version: 1 -# name: test_setup - IssueRegistryItemSnapshot({ - 'created': , - 'dismissed_version': None, - 'domain': 'samsungtv', - 'is_persistent': False, - 'issue_id': 'deprecated_yaml', - }) -# --- diff --git a/tests/components/samsungtv/test_config_flow.py b/tests/components/samsungtv/test_config_flow.py index ac0072c88c..3c4b982b00 100644 --- a/tests/components/samsungtv/test_config_flow.py +++ b/tests/components/samsungtv/test_config_flow.py @@ -1,5 +1,4 @@ """Tests for Samsung TV config flow.""" -import socket from unittest.mock import ANY, AsyncMock, Mock, call, patch import pytest @@ -28,13 +27,9 @@ from homeassistant.components.samsungtv.const import ( DEFAULT_MANUFACTURER, DOMAIN, LEGACY_PORT, - METHOD_ENCRYPTED_WEBSOCKET, - METHOD_LEGACY, - METHOD_WEBSOCKET, RESULT_AUTH_MISSING, RESULT_CANNOT_CONNECT, RESULT_NOT_SUPPORTED, - RESULT_UNKNOWN_HOST, TIMEOUT_REQUEST, TIMEOUT_WEBSOCKET, ) @@ -61,7 +56,6 @@ from homeassistant.data_entry_flow import FlowResultType from homeassistant.setup import async_setup_component from .const import ( - MOCK_CONFIG_ENCRYPTED_WS, MOCK_ENTRYDATA_ENCRYPTED_WS, MOCK_ENTRYDATA_WS, MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, @@ -907,136 +901,6 @@ async def test_ssdp_already_configured(hass: HomeAssistant) -> None: assert entry.unique_id == "123" -@pytest.mark.usefixtures("remote") -async def test_import_legacy(hass: HomeAssistant) -> None: - """Test importing from yaml with hostname.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT_DATA, - ) - await hass.async_block_till_done() - assert result["type"] == "create_entry" - assert result["title"] == "fake" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake" - assert result["data"][CONF_MANUFACTURER] == "Samsung" - assert result["result"].unique_id is None - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].data[CONF_METHOD] == METHOD_LEGACY - assert entries[0].data[CONF_PORT] == LEGACY_PORT - - -@pytest.mark.usefixtures("remote", "remotews", "rest_api_failing") -async def test_import_legacy_without_name( - hass: HomeAssistant, mock_setup_entry: AsyncMock -) -> None: - """Test importing from yaml without a name.""" - with patch( - "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote.start_listening", - side_effect=WebSocketProtocolError("Boom"), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT_DATA_WITHOUT_NAME, - ) - await hass.async_block_till_done() - assert result["type"] == "create_entry" - assert result["title"] == "fake_host" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_MANUFACTURER] == "Samsung" - assert result["result"].unique_id is None - - mock_setup_entry.assert_called_once() - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - # METHOD / PORT failed during import - # They will get checked/set on setup - assert CONF_METHOD not in entries[0].data - assert CONF_PORT not in entries[0].data - - -@pytest.mark.usefixtures("remotews", "rest_api") -async def test_import_websocket(hass: HomeAssistant) -> None: - """Test importing from yaml with hostname.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT_WSDATA, - ) - await hass.async_block_till_done() - assert result["type"] == "create_entry" - assert result["title"] == "fake" - assert result["data"][CONF_METHOD] == METHOD_WEBSOCKET - assert result["data"][CONF_PORT] == 8002 - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake" - assert result["data"][CONF_MANUFACTURER] == "Samsung" - assert result["result"].unique_id is None - - -@pytest.mark.usefixtures("remoteencws") -async def test_import_websocket_encrypted(hass: HomeAssistant) -> None: - """Test importing from yaml with hostname.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_CONFIG_ENCRYPTED_WS, - ) - await hass.async_block_till_done() - - assert result["type"] == "create_entry" - assert result["title"] == "fake" - assert result["data"][CONF_METHOD] == METHOD_ENCRYPTED_WEBSOCKET - assert result["data"][CONF_PORT] == 8000 - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake" - assert result["data"][CONF_MANUFACTURER] == "Samsung" - assert result["result"].unique_id is None - - -@pytest.mark.usefixtures("remotews", "rest_api") -async def test_import_websocket_without_port(hass: HomeAssistant) -> None: - """Test importing from yaml with hostname by no port.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT_WSDATA, - ) - await hass.async_block_till_done() - assert result["type"] == "create_entry" - assert result["title"] == "fake" - assert result["data"][CONF_HOST] == "fake_host" - assert result["data"][CONF_NAME] == "fake" - assert result["data"][CONF_MANUFACTURER] == "Samsung" - assert result["result"].unique_id is None - - entries = hass.config_entries.async_entries(DOMAIN) - assert len(entries) == 1 - assert entries[0].data[CONF_METHOD] == METHOD_WEBSOCKET - assert entries[0].data[CONF_PORT] == 8002 - - -@pytest.mark.usefixtures("remotews") -async def test_import_unknown_host(hass: HomeAssistant) -> None: - """Test importing from yaml with hostname that does not resolve.""" - with patch( - "homeassistant.components.samsungtv.config_flow.socket.gethostbyname", - side_effect=socket.gaierror, - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_IMPORT}, - data=MOCK_IMPORT_DATA, - ) - await hass.async_block_till_done() - assert result["type"] == "abort" - assert result["reason"] == RESULT_UNKNOWN_HOST - - @pytest.mark.usefixtures("remotews", "rest_api_non_ssl_only", "remoteencws_failing") async def test_dhcp_wireless(hass: HomeAssistant) -> None: """Test starting a flow from dhcp.""" @@ -1389,18 +1253,14 @@ async def test_update_missing_mac_unique_id_added_from_dhcp( """Test missing mac and unique id added.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY, unique_id=None) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=MOCK_DHCP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=MOCK_DHCP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1415,18 +1275,15 @@ async def test_update_missing_mac_unique_id_added_from_zeroconf( """Test missing mac and unique id added.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY, unique_id=None) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=MOCK_ZEROCONF_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == "abort" assert result["reason"] == "already_configured" assert entry.data[CONF_MAC] == "aa:bb:zz:ee:rr:oo" @@ -1444,18 +1301,14 @@ async def test_update_missing_model_added_from_ssdp( unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1469,18 +1322,14 @@ async def test_update_missing_mac_unique_id_ssdp_location_added_from_ssdp( """Test missing mac, ssdp_location, and unique id added via ssdp.""" entry = MockConfigEntry(domain=DOMAIN, data=MOCK_OLD_ENTRY, unique_id=None) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1529,18 +1378,14 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_updated_from_ssd unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1566,18 +1411,14 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_rendering_st_upd unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1605,18 +1446,14 @@ async def test_update_missing_mac_unique_id_added_ssdp_location_main_tv_agent_st unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1644,18 +1481,14 @@ async def test_update_ssdp_location_rendering_st_updated_from_ssdp( unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1679,18 +1512,14 @@ async def test_update_main_tv_ssdp_location_rendering_st_updated_from_ssdp( unique_id="be9554b9-c9fb-41f4-8920-22da015376a4", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_MAIN_TV_AGENT_ST, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1714,18 +1543,15 @@ async def test_update_missing_mac_added_unique_id_preserved_from_zeroconf( unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_ZEROCONF}, - data=MOCK_ZEROCONF_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_ZEROCONF}, + data=MOCK_ZEROCONF_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == "abort" assert result["reason"] == "already_configured" assert entry.data[CONF_MAC] == "aa:bb:zz:ee:rr:oo" @@ -1743,20 +1569,17 @@ async def test_update_legacy_missing_mac_from_dhcp( unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=dhcp.DhcpServiceInfo( - ip=EXISTING_IP, macaddress="aa:bb:cc:dd:ee:ff", hostname="fake_hostname" - ), - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=dhcp.DhcpServiceInfo( + ip=EXISTING_IP, macaddress="aa:bb:cc:dd:ee:ff", hostname="fake_hostname" + ), + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == "abort" assert result["reason"] == "already_configured" assert entry.data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" @@ -1780,10 +1603,7 @@ async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( ), patch( "homeassistant.components.samsungtv.bridge.SamsungTVEncryptedWSAsyncRemote.start_listening", side_effect=WebSocketProtocolError("Boom"), - ), patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: + ): result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_DHCP}, @@ -1792,8 +1612,8 @@ async def test_update_legacy_missing_mac_from_dhcp_no_unique_id( ), ) await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 + assert result["type"] == "abort" assert result["reason"] == "not_supported" assert entry.data[CONF_MAC] == "aa:bb:cc:dd:ee:ff" @@ -1811,18 +1631,14 @@ async def test_update_ssdp_location_unique_id_added_from_ssdp( unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -1843,18 +1659,14 @@ async def test_update_ssdp_location_unique_id_added_from_ssdp_with_rendering_con unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA_RENDERING_CONTROL_ST, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -2060,18 +1872,14 @@ async def test_update_incorrect_udn_matching_upnp_udn_unique_id_added_from_ssdp( unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -2090,18 +1898,14 @@ async def test_update_incorrect_udn_matching_mac_unique_id_added_from_ssdp( unique_id=None, ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_SSDP}, - data=MOCK_SSDP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_SSDP}, + data=MOCK_SSDP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -2121,18 +1925,14 @@ async def test_update_incorrect_udn_matching_mac_from_dhcp( unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=MOCK_DHCP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 1 - assert len(mock_setup_entry.mock_calls) == 1 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=MOCK_DHCP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 assert result["type"] == "abort" assert result["reason"] == "already_configured" @@ -2152,18 +1952,14 @@ async def test_no_update_incorrect_udn_not_matching_mac_from_dhcp( unique_id="0d1cef00-00dc-1000-9c80-4844f7b172de", ) entry.add_to_hass(hass) - with patch( - "homeassistant.components.samsungtv.async_setup", - return_value=True, - ) as mock_setup: - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": config_entries.SOURCE_DHCP}, - data=MOCK_DHCP_DATA, - ) - await hass.async_block_till_done() - assert len(mock_setup.mock_calls) == 0 - assert len(mock_setup_entry.mock_calls) == 0 + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": config_entries.SOURCE_DHCP}, + data=MOCK_DHCP_DATA, + ) + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 0 assert result["type"] == "form" assert result["step_id"] == "confirm" diff --git a/tests/components/samsungtv/test_init.py b/tests/components/samsungtv/test_init.py index 24b3e7d4c7..e15d84913b 100644 --- a/tests/components/samsungtv/test_init.py +++ b/tests/components/samsungtv/test_init.py @@ -2,12 +2,10 @@ from unittest.mock import Mock, patch import pytest -from syrupy.assertion import SnapshotAssertion from homeassistant.components.media_player import DOMAIN, MediaPlayerEntityFeature from homeassistant.components.samsungtv.const import ( CONF_MANUFACTURER, - CONF_ON_ACTION, CONF_SESSION_ID, CONF_SSDP_MAIN_TV_AGENT_LOCATION, CONF_SSDP_RENDERING_CONTROL_LOCATION, @@ -32,8 +30,6 @@ from homeassistant.const import ( SERVICE_VOLUME_UP, ) from homeassistant.core import HomeAssistant -from homeassistant.helpers import issue_registry as ir -from homeassistant.setup import async_setup_component from . import setup_samsungtv_entry from .const import ( @@ -48,43 +44,16 @@ from tests.common import MockConfigEntry ENTITY_ID = f"{DOMAIN}.fake_name" MOCK_CONFIG = { - SAMSUNGTV_DOMAIN: [ - { - CONF_HOST: "fake_host", - CONF_NAME: "fake_name", - CONF_ON_ACTION: [{"delay": "00:00:01"}], - CONF_METHOD: METHOD_WEBSOCKET, - } - ] -} -MOCK_CONFIG_WITHOUT_PORT = { - SAMSUNGTV_DOMAIN: [ - { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_ON_ACTION: [{"delay": "00:00:01"}], - } - ] -} - -REMOTE_CALL = { - "name": "HomeAssistant", - "description": "HomeAssistant", - "id": "ha.component.samsung", - "host": MOCK_CONFIG[SAMSUNGTV_DOMAIN][0][CONF_HOST], - "method": "legacy", - "port": None, - "timeout": 1, + CONF_HOST: "fake_host", + CONF_NAME: "fake_name", + CONF_METHOD: METHOD_WEBSOCKET, } @pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup( - hass: HomeAssistant, issue_registry: ir.IssueRegistry, snapshot: SnapshotAssertion -) -> None: +async def test_setup(hass: HomeAssistant) -> None: """Test Samsung TV integration is setup.""" - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() + await setup_samsungtv_entry(hass, MOCK_CONFIG) state = hass.states.get(ENTITY_ID) # test name and turn_on @@ -100,12 +69,8 @@ async def test_setup( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) - # ensure deprecated_yaml issue is raised - issue = issue_registry.async_get_issue(SAMSUNGTV_DOMAIN, "deprecated_yaml") - assert issue == snapshot - -async def test_setup_from_yaml_without_port_device_offline(hass: HomeAssistant) -> None: +async def test_setup_without_port_device_offline(hass: HomeAssistant) -> None: """Test import from yaml when the device is offline.""" with patch( "homeassistant.components.samsungtv.bridge.Remote", side_effect=OSError @@ -119,8 +84,7 @@ async def test_setup_from_yaml_without_port_device_offline(hass: HomeAssistant) "homeassistant.components.samsungtv.bridge.SamsungTVWSBridge.async_device_info", return_value=None, ): - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() + await setup_samsungtv_entry(hass, MOCK_CONFIG) config_entries_domain = hass.config_entries.async_entries(SAMSUNGTV_DOMAIN) assert len(config_entries_domain) == 1 @@ -128,52 +92,22 @@ async def test_setup_from_yaml_without_port_device_offline(hass: HomeAssistant) @pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup_from_yaml_without_port_device_online(hass: HomeAssistant) -> None: +async def test_setup_without_port_device_online(hass: HomeAssistant) -> None: """Test import from yaml when the device is online.""" - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() + await setup_samsungtv_entry(hass, MOCK_CONFIG) config_entries_domain = hass.config_entries.async_entries(SAMSUNGTV_DOMAIN) assert len(config_entries_domain) == 1 assert config_entries_domain[0].data[CONF_MAC] == "aa:bb:ww:ii:ff:ii" -@pytest.mark.usefixtures("remote") -async def test_setup_duplicate_config( - hass: HomeAssistant, caplog: pytest.LogCaptureFixture -) -> None: - """Test duplicate setup of platform.""" - duplicate = { - SAMSUNGTV_DOMAIN: [ - MOCK_CONFIG[SAMSUNGTV_DOMAIN][0], - MOCK_CONFIG[SAMSUNGTV_DOMAIN][0], - ] - } - await async_setup_component(hass, SAMSUNGTV_DOMAIN, duplicate) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID) is None - assert len(hass.states.async_all("media_player")) == 0 - assert "duplicate host entries found" in caplog.text - - -@pytest.mark.usefixtures("remotews", "remoteencws_failing", "rest_api") -async def test_setup_duplicate_entries(hass: HomeAssistant) -> None: - """Test duplicate setup of platform.""" - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - await hass.async_block_till_done() - assert hass.states.get(ENTITY_ID) - assert len(hass.states.async_all("media_player")) == 1 - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) - assert len(hass.states.async_all("media_player")) == 1 - - @pytest.mark.usefixtures("remotews", "remoteencws_failing") async def test_setup_h_j_model( hass: HomeAssistant, rest_api: Mock, caplog: pytest.LogCaptureFixture ) -> None: """Test Samsung TV integration is setup.""" rest_api.rest_device_info.return_value = SAMPLE_DEVICE_INFO_UE48JU6400 - await async_setup_component(hass, SAMSUNGTV_DOMAIN, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) assert state diff --git a/tests/components/samsungtv/test_media_player.py b/tests/components/samsungtv/test_media_player.py index b9fa2202af..1be9982d6c 100644 --- a/tests/components/samsungtv/test_media_player.py +++ b/tests/components/samsungtv/test_media_player.py @@ -33,15 +33,14 @@ from homeassistant.components.media_player import ( SERVICE_PLAY_MEDIA, SERVICE_SELECT_SOURCE, MediaPlayerDeviceClass, - MediaPlayerEntityFeature, MediaType, ) from homeassistant.components.samsungtv.const import ( - CONF_ON_ACTION, CONF_SSDP_RENDERING_CONTROL_LOCATION, DOMAIN as SAMSUNGTV_DOMAIN, ENCRYPTED_WEBSOCKET_PORT, METHOD_ENCRYPTED_WEBSOCKET, + METHOD_LEGACY, METHOD_WEBSOCKET, TIMEOUT_WEBSOCKET, ) @@ -77,8 +76,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError -from homeassistant.helpers.typing import ConfigType -from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util from . import async_wait_config_entry_reload, setup_samsungtv_entry @@ -93,25 +90,17 @@ from tests.common import MockConfigEntry, async_fire_time_changed ENTITY_ID = f"{DOMAIN}.fake" MOCK_CONFIG = { - SAMSUNGTV_DOMAIN: [ - { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 55000, - CONF_ON_ACTION: [{"delay": "00:00:01"}], - } - ] + CONF_HOST: "fake_host", + CONF_NAME: "fake", + CONF_PORT: 55000, + CONF_METHOD: METHOD_LEGACY, } MOCK_CONFIGWS = { - SAMSUNGTV_DOMAIN: [ - { - CONF_HOST: "fake_host", - CONF_NAME: "fake", - CONF_PORT: 8001, - CONF_TOKEN: "123456789", - CONF_ON_ACTION: [{"delay": "00:00:01"}], - } - ] + CONF_HOST: "fake_host", + CONF_NAME: "fake", + CONF_PORT: 8001, + CONF_TOKEN: "123456789", + CONF_METHOD: METHOD_WEBSOCKET, } MOCK_CALLS_WS = { CONF_HOST: "fake_host", @@ -143,43 +132,13 @@ MOCK_ENTRY_WS_WITH_MAC = { } -ENTITY_ID_NOTURNON = f"{DOMAIN}.fake_noturnon" -MOCK_CONFIG_NOTURNON = { - SAMSUNGTV_DOMAIN: [ - {CONF_HOST: "fake_noturnon", CONF_NAME: "fake_noturnon", CONF_PORT: 55000} - ] -} - - -@pytest.fixture(name="delay") -def delay_fixture(): - """Patch the delay script function.""" - with patch( - "homeassistant.components.samsungtv.media_player.Script.async_run" - ) as delay: - yield delay - - -async def setup_samsungtv(hass: HomeAssistant, config: ConfigType) -> None: - """Set up mock Samsung TV.""" - await async_setup_component(hass, SAMSUNGTV_DOMAIN, config) - await hass.async_block_till_done() - - @pytest.mark.usefixtures("remote") -async def test_setup_with_turnon(hass: HomeAssistant) -> None: +async def test_setup(hass: HomeAssistant) -> None: """Test setup of platform.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert hass.states.get(ENTITY_ID) -@pytest.mark.usefixtures("remote") -async def test_setup_without_turnon(hass: HomeAssistant) -> None: - """Test setup of platform.""" - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) - assert hass.states.get(ENTITY_ID_NOTURNON) - - @pytest.mark.usefixtures("remotews", "rest_api") async def test_setup_websocket(hass: HomeAssistant) -> None: """Test setup of platform.""" @@ -192,7 +151,7 @@ async def test_setup_websocket(hass: HomeAssistant) -> None: remote.token = "123456789" remote_class.return_value = remote - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) assert remote_class.call_count == 1 assert remote_class.call_args_list == [call(**MOCK_CALLS_WS)] @@ -229,7 +188,7 @@ async def test_setup_websocket_2(hass: HomeAssistant, mock_now: datetime) -> Non remote.__aexit__ = AsyncMock() remote.token = "987654321" remote_class.return_value = remote - assert await async_setup_component(hass, SAMSUNGTV_DOMAIN, {}) + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() assert config_entries[0].data[CONF_MAC] == "aa:bb:ww:ii:ff:ii" @@ -272,7 +231,7 @@ async def test_setup_encrypted_websocket( @pytest.mark.usefixtures("remote") async def test_update_on(hass: HomeAssistant, mock_now: datetime) -> None: """Testing update tv on.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) next_update = mock_now + timedelta(minutes=5) with patch("homeassistant.util.dt.utcnow", return_value=next_update): @@ -286,7 +245,7 @@ async def test_update_on(hass: HomeAssistant, mock_now: datetime) -> None: @pytest.mark.usefixtures("remote") async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: """Testing update tv off.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -298,14 +257,14 @@ async def test_update_off(hass: HomeAssistant, mock_now: datetime) -> None: await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE async def test_update_off_ws_no_power_state( hass: HomeAssistant, remotews: Mock, rest_api: Mock, mock_now: datetime ) -> None: """Testing update tv off.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) # device_info should only get called once, as part of the setup rest_api.rest_device_info.assert_called_once() rest_api.rest_device_info.reset_mock() @@ -336,13 +295,13 @@ async def test_update_off_ws_with_power_state( ) as mock_device_info, patch.object( remotews, "start_listening", side_effect=WebSocketException("Boom") ) as mock_start_listening: - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) mock_device_info.assert_called_once() mock_start_listening.assert_called_once() state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE # First update uses start_listening once, and initialises device_info device_info = deepcopy(SAMPLE_DEVICE_INFO_WIFI) @@ -385,7 +344,7 @@ async def test_update_off_ws_with_power_state( rest_api.rest_device_info.assert_called_once() state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE remotews.start_listening.assert_not_called() @@ -417,7 +376,7 @@ async def test_update_off_encryptedws( @pytest.mark.usefixtures("remote") async def test_update_access_denied(hass: HomeAssistant, mock_now: datetime) -> None: """Testing update tv access denied exception.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -449,7 +408,7 @@ async def test_update_ws_connection_failure( caplog: pytest.LogCaptureFixture, ) -> None: """Testing update tv connection failure exception.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) with patch.object( remotews, @@ -476,7 +435,7 @@ async def test_update_ws_connection_closed( hass: HomeAssistant, mock_now: datetime, remotews: Mock ) -> None: """Testing update tv connection failure exception.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) with patch.object( remotews, "start_listening", side_effect=ConnectionClosedError(None, None) @@ -495,7 +454,7 @@ async def test_update_ws_unauthorized_error( hass: HomeAssistant, mock_now: datetime, remotews: Mock ) -> None: """Testing update tv unauthorized failure exception.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) with patch.object( remotews, "start_listening", side_effect=UnauthorizedError @@ -519,7 +478,7 @@ async def test_update_unhandled_response( hass: HomeAssistant, mock_now: datetime ) -> None: """Testing update tv unhandled response exception.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -539,7 +498,7 @@ async def test_connection_closed_during_update_can_recover( hass: HomeAssistant, mock_now: datetime ) -> None: """Testing update tv connection closed exception can recover.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch( "homeassistant.components.samsungtv.bridge.Remote", @@ -551,7 +510,7 @@ async def test_connection_closed_during_update_can_recover( await hass.async_block_till_done() state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF + assert state.state == STATE_UNAVAILABLE next_update = mock_now + timedelta(minutes=10) with patch("homeassistant.util.dt.utcnow", return_value=next_update): @@ -564,7 +523,7 @@ async def test_connection_closed_during_update_can_recover( async def test_send_key(hass: HomeAssistant, remote: Mock) -> None: """Test for send key.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -579,7 +538,7 @@ async def test_send_key(hass: HomeAssistant, remote: Mock) -> None: async def test_send_key_broken_pipe(hass: HomeAssistant, remote: Mock) -> None: """Testing broken pipe Exception.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.control = Mock(side_effect=BrokenPipeError("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -592,7 +551,7 @@ async def test_send_key_connection_closed_retry_succeed( hass: HomeAssistant, remote: Mock ) -> None: """Test retry on connection closed.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.control = Mock( side_effect=[exceptions.ConnectionClosed("Boom"), DEFAULT_MOCK, DEFAULT_MOCK] ) @@ -613,7 +572,7 @@ async def test_send_key_connection_closed_retry_succeed( async def test_send_key_unhandled_response(hass: HomeAssistant, remote: Mock) -> None: """Testing unhandled response exception.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.control = Mock(side_effect=exceptions.UnhandledResponse("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -625,7 +584,7 @@ async def test_send_key_unhandled_response(hass: HomeAssistant, remote: Mock) -> @pytest.mark.usefixtures("rest_api") async def test_send_key_websocketexception(hass: HomeAssistant, remotews: Mock) -> None: """Testing unhandled response exception.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands = Mock(side_effect=WebSocketException("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -651,7 +610,7 @@ async def test_send_key_websocketexception_encrypted( @pytest.mark.usefixtures("rest_api") async def test_send_key_os_error_ws(hass: HomeAssistant, remotews: Mock) -> None: """Testing unhandled response exception.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands = Mock(side_effect=OSError("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -676,7 +635,7 @@ async def test_send_key_os_error_ws_encrypted( async def test_send_key_os_error(hass: HomeAssistant, remote: Mock) -> None: """Testing broken pipe Exception.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.control = Mock(side_effect=OSError("Boom")) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -688,42 +647,24 @@ async def test_send_key_os_error(hass: HomeAssistant, remote: Mock) -> None: @pytest.mark.usefixtures("remote") async def test_name(hass: HomeAssistant) -> None: """Test for name property.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_FRIENDLY_NAME] == "fake" @pytest.mark.usefixtures("remote") -async def test_state_with_turnon(hass: HomeAssistant, delay: Mock) -> None: +async def test_state(hass: HomeAssistant) -> None: """Test for state property.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True + DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) assert state.state == STATE_ON - assert delay.call_count == 1 - assert await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) state = hass.states.get(ENTITY_ID) - assert state.state == STATE_OFF - - -@pytest.mark.usefixtures("remote") -async def test_state_without_turnon(hass: HomeAssistant) -> None: - """Test for state property.""" - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) - assert await hass.services.async_call( - DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True - ) - state = hass.states.get(ENTITY_ID_NOTURNON) - assert state.state == STATE_ON - assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True - ) - state = hass.states.get(ENTITY_ID_NOTURNON) # Should be STATE_UNAVAILABLE after the timer expires assert state.state == STATE_OFF @@ -735,34 +676,23 @@ async def test_state_without_turnon(hass: HomeAssistant) -> None: async_fire_time_changed(hass, next_update) await hass.async_block_till_done() - state = hass.states.get(ENTITY_ID_NOTURNON) + state = hass.states.get(ENTITY_ID) # Should be STATE_UNAVAILABLE since there is no way to turn it back on assert state.state == STATE_UNAVAILABLE @pytest.mark.usefixtures("remote") -async def test_supported_features_with_turnon(hass: HomeAssistant) -> None: +async def test_supported_features(hass: HomeAssistant) -> None: """Test for supported_features property.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) state = hass.states.get(ENTITY_ID) - assert ( - state.attributes[ATTR_SUPPORTED_FEATURES] - == SUPPORT_SAMSUNGTV | MediaPlayerEntityFeature.TURN_ON - ) - - -@pytest.mark.usefixtures("remote") -async def test_supported_features_without_turnon(hass: HomeAssistant) -> None: - """Test for supported_features property.""" - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) - state = hass.states.get(ENTITY_ID_NOTURNON) assert state.attributes[ATTR_SUPPORTED_FEATURES] == SUPPORT_SAMSUNGTV @pytest.mark.usefixtures("remote") async def test_device_class(hass: HomeAssistant) -> None: """Test for device_class property.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) state = hass.states.get(ENTITY_ID) assert state.attributes[ATTR_DEVICE_CLASS] is MediaPlayerDeviceClass.TV.value @@ -777,7 +707,7 @@ async def test_turn_off_websocket( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), DEFAULT_MOCK], ): - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands.reset_mock() @@ -816,7 +746,7 @@ async def test_turn_off_websocket_frame( "homeassistant.components.samsungtv.bridge.Remote", side_effect=[OSError("Boom"), DEFAULT_MOCK], ): - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands.reset_mock() @@ -903,9 +833,9 @@ async def test_turn_off_encrypted_websocket_key_type( async def test_turn_off_legacy(hass: HomeAssistant, remote: Mock) -> None: """Test for turn_off.""" - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True + DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # key called assert remote.control.call_count == 1 @@ -917,7 +847,7 @@ async def test_turn_off_os_error( ) -> None: """Test for turn_off with OSError.""" caplog.set_level(logging.DEBUG) - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.close = Mock(side_effect=OSError("BOOM")) assert await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -931,7 +861,7 @@ async def test_turn_off_ws_os_error( ) -> None: """Test for turn_off with OSError.""" caplog.set_level(logging.DEBUG) - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.close = Mock(side_effect=OSError("BOOM")) assert await hass.services.async_call( DOMAIN, SERVICE_TURN_OFF, {ATTR_ENTITY_ID: ENTITY_ID}, True @@ -955,7 +885,7 @@ async def test_turn_off_encryptedws_os_error( async def test_volume_up(hass: HomeAssistant, remote: Mock) -> None: """Test for volume_up.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_UP, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -968,7 +898,7 @@ async def test_volume_up(hass: HomeAssistant, remote: Mock) -> None: async def test_volume_down(hass: HomeAssistant, remote: Mock) -> None: """Test for volume_down.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_DOWN, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -981,7 +911,7 @@ async def test_volume_down(hass: HomeAssistant, remote: Mock) -> None: async def test_mute_volume(hass: HomeAssistant, remote: Mock) -> None: """Test for mute_volume.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_VOLUME_MUTE, @@ -997,7 +927,7 @@ async def test_mute_volume(hass: HomeAssistant, remote: Mock) -> None: async def test_media_play(hass: HomeAssistant, remote: Mock) -> None: """Test for media_play.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PLAY, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -1019,7 +949,7 @@ async def test_media_play(hass: HomeAssistant, remote: Mock) -> None: async def test_media_pause(hass: HomeAssistant, remote: Mock) -> None: """Test for media_pause.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PAUSE, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -1041,7 +971,7 @@ async def test_media_pause(hass: HomeAssistant, remote: Mock) -> None: async def test_media_next_track(hass: HomeAssistant, remote: Mock) -> None: """Test for media_next_track.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_MEDIA_NEXT_TRACK, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -1054,7 +984,7 @@ async def test_media_next_track(hass: HomeAssistant, remote: Mock) -> None: async def test_media_previous_track(hass: HomeAssistant, remote: Mock) -> None: """Test for media_previous_track.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_MEDIA_PREVIOUS_TRACK, {ATTR_ENTITY_ID: ENTITY_ID}, True ) @@ -1065,16 +995,6 @@ async def test_media_previous_track(hass: HomeAssistant, remote: Mock) -> None: assert remote.close.call_args_list == [call()] -@pytest.mark.usefixtures("remote") -async def test_turn_on_with_turnon(hass: HomeAssistant, delay: Mock) -> None: - """Test turn on.""" - await setup_samsungtv(hass, MOCK_CONFIG) - assert await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True - ) - assert delay.call_count == 1 - - @pytest.mark.usefixtures("remotews", "rest_api") async def test_turn_on_wol(hass: HomeAssistant) -> None: """Test turn on.""" @@ -1084,7 +1004,7 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: unique_id="any", ) entry.add_to_hass(hass) - assert await async_setup_component(hass, SAMSUNGTV_DOMAIN, {}) + assert await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() with patch( "homeassistant.components.samsungtv.media_player.send_magic_packet" @@ -1098,10 +1018,10 @@ async def test_turn_on_wol(hass: HomeAssistant) -> None: async def test_turn_on_without_turnon(hass: HomeAssistant, remote: Mock) -> None: """Test turn on.""" - await setup_samsungtv(hass, MOCK_CONFIG_NOTURNON) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with pytest.raises(HomeAssistantError): await hass.services.async_call( - DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID_NOTURNON}, True + DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: ENTITY_ID}, True ) # nothing called as not supported feature assert remote.control.call_count == 0 @@ -1109,7 +1029,7 @@ async def test_turn_on_without_turnon(hass: HomeAssistant, remote: Mock) -> None async def test_play_media(hass: HomeAssistant, remote: Mock) -> None: """Test for play_media.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) with patch("homeassistant.components.samsungtv.bridge.asyncio.sleep") as sleep: assert await hass.services.async_call( DOMAIN, @@ -1138,7 +1058,7 @@ async def test_play_media_invalid_type(hass: HomeAssistant) -> None: """Test for play_media with invalid media type.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: url = "https://example.com" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() assert await hass.services.async_call( DOMAIN, @@ -1160,7 +1080,7 @@ async def test_play_media_channel_as_string(hass: HomeAssistant) -> None: """Test for play_media with invalid channel as string.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: url = "https://example.com" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() assert await hass.services.async_call( DOMAIN, @@ -1181,7 +1101,7 @@ async def test_play_media_channel_as_string(hass: HomeAssistant) -> None: async def test_play_media_channel_as_non_positive(hass: HomeAssistant) -> None: """Test for play_media with invalid channel as non positive integer.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() assert await hass.services.async_call( DOMAIN, @@ -1201,7 +1121,7 @@ async def test_play_media_channel_as_non_positive(hass: HomeAssistant) -> None: async def test_select_source(hass: HomeAssistant, remote: Mock) -> None: """Test for select_source.""" - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) assert await hass.services.async_call( DOMAIN, SERVICE_SELECT_SOURCE, @@ -1218,7 +1138,7 @@ async def test_select_source(hass: HomeAssistant, remote: Mock) -> None: async def test_select_source_invalid_source(hass: HomeAssistant) -> None: """Test for select_source with invalid source.""" with patch("homeassistant.components.samsungtv.bridge.Remote") as remote: - await setup_samsungtv(hass, MOCK_CONFIG) + await setup_samsungtv_entry(hass, MOCK_CONFIG) remote.reset_mock() assert await hass.services.async_call( DOMAIN, @@ -1235,7 +1155,7 @@ async def test_select_source_invalid_source(hass: HomeAssistant) -> None: @pytest.mark.usefixtures("rest_api") async def test_play_media_app(hass: HomeAssistant, remotews: Mock) -> None: """Test for play_media.""" - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands.reset_mock() assert await hass.services.async_call( @@ -1259,7 +1179,7 @@ async def test_play_media_app(hass: HomeAssistant, remotews: Mock) -> None: async def test_select_source_app(hass: HomeAssistant, remotews: Mock) -> None: """Test for select_source.""" remotews.app_list_data = SAMPLE_EVENT_ED_INSTALLED_APP - await setup_samsungtv(hass, MOCK_CONFIGWS) + await setup_samsungtv_entry(hass, MOCK_CONFIGWS) remotews.send_commands.reset_mock() assert await hass.services.async_call( From 2721874f13fc5047517e80cb6493399da40f0e1a Mon Sep 17 00:00:00 2001 From: erikbadman Date: Tue, 23 May 2023 11:42:54 +0200 Subject: [PATCH 10/15] Get kostal plenticore hostname id from get_settings (#93008) * Get hostname id from get_settings * Add try except in get_hostname_id function * Update tests after adding get_hostname_id function * Revert "Update tests after adding get_hostname_id function" This reverts commit 5fa4e533cb18e8d141dbb1b7aed021f80f4312a2. * Add test for G2 models in config flow. * Add test for helper module. * Fix test for numbers. * Revert "Add try except in get_hostname_id function" This reverts commit 059f5bd9b413ca060300e09a6a1f0dffb4420f56. * Update variable name with known hostname ids to be private --------- Co-authored-by: Stefan Gmeiner --- .../kostal_plenticore/config_flow.py | 6 +- .../components/kostal_plenticore/helper.py | 15 ++- .../kostal_plenticore/test_config_flow.py | 118 +++++++++++++++--- .../kostal_plenticore/test_helper.py | 107 ++++++++++++++++ .../kostal_plenticore/test_number.py | 32 ++++- 5 files changed, 252 insertions(+), 26 deletions(-) create mode 100644 tests/components/kostal_plenticore/test_helper.py diff --git a/homeassistant/components/kostal_plenticore/config_flow.py b/homeassistant/components/kostal_plenticore/config_flow.py index cbbaeefd85..ba8e762763 100644 --- a/homeassistant/components/kostal_plenticore/config_flow.py +++ b/homeassistant/components/kostal_plenticore/config_flow.py @@ -12,6 +12,7 @@ from homeassistant.core import HomeAssistant from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import DOMAIN +from .helper import get_hostname_id _LOGGER = logging.getLogger(__name__) @@ -32,9 +33,10 @@ async def test_connection(hass: HomeAssistant, data) -> str: session = async_get_clientsession(hass) async with ApiClient(session, data["host"]) as client: await client.login(data["password"]) - values = await client.get_setting_values("scb:network", "Hostname") + hostname_id = await get_hostname_id(client) + values = await client.get_setting_values("scb:network", hostname_id) - return values["scb:network"]["Hostname"] + return values["scb:network"][hostname_id] class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN): diff --git a/homeassistant/components/kostal_plenticore/helper.py b/homeassistant/components/kostal_plenticore/helper.py index a91fb24aad..35ec7bb945 100644 --- a/homeassistant/components/kostal_plenticore/helper.py +++ b/homeassistant/components/kostal_plenticore/helper.py @@ -23,6 +23,7 @@ from .const import DOMAIN _LOGGER = logging.getLogger(__name__) _DataT = TypeVar("_DataT") +_KNOWN_HOSTNAME_IDS = ("Network:Hostname", "Hostname") class Plenticore: @@ -69,6 +70,7 @@ class Plenticore: ) # get some device meta data + hostname_id = await get_hostname_id(self._client) settings = await self._client.get_setting_values( { "devices:local": [ @@ -78,7 +80,7 @@ class Plenticore: "Properties:VersionIOC", "Properties:VersionMC", ], - "scb:network": ["Hostname"], + "scb:network": [hostname_id], } ) @@ -91,7 +93,7 @@ class Plenticore: identifiers={(DOMAIN, device_local["Properties:SerialNo"])}, manufacturer="Kostal", model=f"{prod1} {prod2}", - name=settings["scb:network"]["Hostname"], + name=settings["scb:network"][hostname_id], sw_version=f'IOC: {device_local["Properties:VersionIOC"]}' + f' MC: {device_local["Properties:VersionMC"]}', ) @@ -403,3 +405,12 @@ class PlenticoreDataFormatter: return state return PlenticoreDataFormatter.EM_STATES.get(value) + + +async def get_hostname_id(client: ApiClient) -> str: + """Check for known existing hostname ids.""" + all_settings = await client.get_settings() + for entry in all_settings["scb:network"]: + if entry.id in _KNOWN_HOSTNAME_IDS: + return entry.id + raise ApiException("Hostname identifier not found in KNOWN_HOSTNAME_IDS") diff --git a/tests/components/kostal_plenticore/test_config_flow.py b/tests/components/kostal_plenticore/test_config_flow.py index 3c64a48c21..41facfe9c2 100644 --- a/tests/components/kostal_plenticore/test_config_flow.py +++ b/tests/components/kostal_plenticore/test_config_flow.py @@ -1,8 +1,10 @@ """Test the Kostal Plenticore Solar Inverter config flow.""" import asyncio +from collections.abc import Generator from unittest.mock import ANY, AsyncMock, MagicMock, patch -from pykoplenti import AuthenticationException +from pykoplenti import ApiClient, AuthenticationException, SettingsData +import pytest from homeassistant import config_entries from homeassistant.components.kostal_plenticore.const import DOMAIN @@ -11,8 +13,33 @@ from homeassistant.core import HomeAssistant from tests.common import MockConfigEntry -async def test_formx(hass: HomeAssistant) -> None: - """Test we get the form.""" +@pytest.fixture +def mock_apiclient() -> ApiClient: + """Return a mocked ApiClient instance.""" + apiclient = MagicMock(spec=ApiClient) + apiclient.__aenter__.return_value = apiclient + apiclient.__aexit__ = AsyncMock() + + return apiclient + + +@pytest.fixture +def mock_apiclient_class(mock_apiclient) -> Generator[type[ApiClient], None, None]: + """Return a mocked ApiClient class.""" + with patch( + "homeassistant.components.kostal_plenticore.config_flow.ApiClient", + autospec=True, + ) as mock_api_class: + mock_api_class.return_value = mock_apiclient + yield mock_api_class + + +async def test_form_g1( + hass: HomeAssistant, + mock_apiclient_class: type[ApiClient], + mock_apiclient: ApiClient, +) -> None: + """Test the config flow for G1 models.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -21,25 +48,19 @@ async def test_formx(hass: HomeAssistant) -> None: assert result["errors"] == {} with patch( - "homeassistant.components.kostal_plenticore.config_flow.ApiClient" - ) as mock_api_class, patch( "homeassistant.components.kostal_plenticore.async_setup_entry", return_value=True, ) as mock_setup_entry: # mock of the context manager instance - mock_api_ctx = MagicMock() - mock_api_ctx.login = AsyncMock() - mock_api_ctx.get_setting_values = AsyncMock( + mock_apiclient.login = AsyncMock() + mock_apiclient.get_settings = AsyncMock( + return_value={"scb:network": [SettingsData({"id": "Hostname"})]} + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" return_value={"scb:network": {"Hostname": "scb"}} ) - # mock of the return instance of ApiClient - mock_api = MagicMock() - mock_api.__aenter__.return_value = mock_api_ctx - mock_api.__aexit__ = AsyncMock() - - mock_api_class.return_value = mock_api - result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { @@ -49,11 +70,68 @@ async def test_formx(hass: HomeAssistant) -> None: ) await hass.async_block_till_done() - mock_api_class.assert_called_once_with(ANY, "1.1.1.1") - mock_api.__aenter__.assert_called_once() - mock_api.__aexit__.assert_called_once() - mock_api_ctx.login.assert_called_once_with("test-password") - mock_api_ctx.get_setting_values.assert_called_once() + mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1") + mock_apiclient.__aenter__.assert_called_once() + mock_apiclient.__aexit__.assert_called_once() + mock_apiclient.login.assert_called_once_with("test-password") + mock_apiclient.get_settings.assert_called_once() + mock_apiclient.get_setting_values.assert_called_once_with( + "scb:network", "Hostname" + ) + + assert result2["type"] == "create_entry" + assert result2["title"] == "scb" + assert result2["data"] == { + "host": "1.1.1.1", + "password": "test-password", + } + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_g2( + hass: HomeAssistant, + mock_apiclient_class: type[ApiClient], + mock_apiclient: ApiClient, +) -> None: + """Test the config flow for G2 models.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] == "form" + assert result["errors"] == {} + + with patch( + "homeassistant.components.kostal_plenticore.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + # mock of the context manager instance + mock_apiclient.login = AsyncMock() + mock_apiclient.get_settings = AsyncMock( + return_value={"scb:network": [SettingsData({"id": "Network:Hostname"})]} + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" + return_value={"scb:network": {"Network:Hostname": "scb"}} + ) + + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "host": "1.1.1.1", + "password": "test-password", + }, + ) + await hass.async_block_till_done() + + mock_apiclient_class.assert_called_once_with(ANY, "1.1.1.1") + mock_apiclient.__aenter__.assert_called_once() + mock_apiclient.__aexit__.assert_called_once() + mock_apiclient.login.assert_called_once_with("test-password") + mock_apiclient.get_settings.assert_called_once() + mock_apiclient.get_setting_values.assert_called_once_with( + "scb:network", "Network:Hostname" + ) assert result2["type"] == "create_entry" assert result2["title"] == "scb" diff --git a/tests/components/kostal_plenticore/test_helper.py b/tests/components/kostal_plenticore/test_helper.py new file mode 100644 index 0000000000..cc522c9697 --- /dev/null +++ b/tests/components/kostal_plenticore/test_helper.py @@ -0,0 +1,107 @@ +"""Test Kostal Plenticore helper.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +from pykoplenti import ApiClient, SettingsData +import pytest + +from homeassistant.components.kostal_plenticore.const import DOMAIN +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity import DeviceInfo + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_apiclient() -> Generator[ApiClient, None, None]: + """Return a mocked ApiClient class.""" + with patch( + "homeassistant.components.kostal_plenticore.helper.ApiClient", + autospec=True, + ) as mock_api_class: + apiclient = MagicMock(spec=ApiClient) + apiclient.__aenter__.return_value = apiclient + apiclient.__aexit__ = AsyncMock() + mock_api_class.return_value = apiclient + yield apiclient + + +async def test_plenticore_async_setup_g1( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_apiclient: ApiClient, +) -> None: + """Tests the async_setup() method of the Plenticore class for G1 models.""" + mock_apiclient.get_settings = AsyncMock( + return_value={"scb:network": [SettingsData({"id": "Hostname"})]} + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" + return_value={ + "devices:local": { + "Properties:SerialNo": "12345", + "Branding:ProductName1": "PLENTICORE", + "Branding:ProductName2": "plus 10", + "Properties:VersionIOC": "01.45", + "Properties:VersionMC": "01.46", + }, + "scb:network": {"Hostname": "scb"}, + } + ) + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + plenticore = hass.data[DOMAIN][mock_config_entry.entry_id] + + assert plenticore.device_info == DeviceInfo( + configuration_url="http://192.168.1.2", + identifiers={(DOMAIN, "12345")}, + manufacturer="Kostal", + model="PLENTICORE plus 10", + name="scb", + sw_version="IOC: 01.45 MC: 01.46", + ) + + +async def test_plenticore_async_setup_g2( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_apiclient: ApiClient, +) -> None: + """Tests the async_setup() method of the Plenticore class for G2 models.""" + mock_apiclient.get_settings = AsyncMock( + return_value={"scb:network": [SettingsData({"id": "Network:Hostname"})]} + ) + mock_apiclient.get_setting_values = AsyncMock( + # G1 model has the entry id "Hostname" + return_value={ + "devices:local": { + "Properties:SerialNo": "12345", + "Branding:ProductName1": "PLENTICORE", + "Branding:ProductName2": "plus 10", + "Properties:VersionIOC": "01.45", + "Properties:VersionMC": "01.46", + }, + "scb:network": {"Network:Hostname": "scb"}, + } + ) + + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + plenticore = hass.data[DOMAIN][mock_config_entry.entry_id] + + assert plenticore.device_info == DeviceInfo( + configuration_url="http://192.168.1.2", + identifiers={(DOMAIN, "12345")}, + manufacturer="Kostal", + model="PLENTICORE plus 10", + name="scb", + sw_version="IOC: 01.45 MC: 01.46", + ) diff --git a/tests/components/kostal_plenticore/test_number.py b/tests/components/kostal_plenticore/test_number.py index beabd8fe66..009184a699 100644 --- a/tests/components/kostal_plenticore/test_number.py +++ b/tests/components/kostal_plenticore/test_number.py @@ -62,7 +62,20 @@ def mock_get_setting_values(mock_plenticore_client: ApiClient) -> list: "id": "Battery:MinHomeComsumption", } ), - ] + ], + "scb:network": [ + SettingsData( + { + "min": "1", + "default": None, + "access": "readwrite", + "unit": None, + "id": "Hostname", + "type": "string", + "max": "63", + } + ) + ], } # this values are always retrieved by the integration on startup @@ -112,7 +125,22 @@ async def test_setup_no_entries( ) -> None: """Test that no entries are setup if Plenticore does not provide data.""" - mock_plenticore_client.get_settings.return_value = [] + # remove all settings except hostname which is used during setup + mock_plenticore_client.get_settings.return_value = { + "scb:network": [ + SettingsData( + { + "min": "1", + "default": None, + "access": "readwrite", + "unit": None, + "id": "Hostname", + "type": "string", + "max": "63", + } + ) + ], + } mock_config_entry.add_to_hass(hass) From c23718d13db00bbbf5189ddc0dccdcfbdd35ee31 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Tue, 23 May 2023 12:06:19 +0200 Subject: [PATCH 11/15] Cleanup samsungtv strings (#93395) --- homeassistant/components/samsungtv/strings.json | 6 ------ 1 file changed, 6 deletions(-) diff --git a/homeassistant/components/samsungtv/strings.json b/homeassistant/components/samsungtv/strings.json index cfa04244e8..f1f237fa4f 100644 --- a/homeassistant/components/samsungtv/strings.json +++ b/homeassistant/components/samsungtv/strings.json @@ -44,11 +44,5 @@ "trigger_type": { "samsungtv.turn_on": "Device is requested to turn on" } - }, - "issues": { - "deprecated_yaml": { - "title": "The SamsungTV YAML configuration is being removed", - "description": "Configuring SamsungTV using YAML is being removed.\n\nYour existing YAML configuration has been imported into the UI automatically.\n\nRemove the SamsungTV YAML configuration from your `configuration.yaml` file and restart Home Assistant to fix this issue.\n\nPlease note that previously configured `turn_on_action` needs to be manually converted to use the `turn_on` trigger ([documentation]({on_action_url}))." - } } } From 497fa1980bc613c323b51bb90e118959b2e437b8 Mon Sep 17 00:00:00 2001 From: RenierM26 <66512715+RenierM26@users.noreply.github.com> Date: Tue, 23 May 2023 12:32:14 +0200 Subject: [PATCH 12/15] Add Ezviz update entity (#85377) Co-authored-by: Franck Nijhof --- .coveragerc | 1 + homeassistant/components/ezviz/__init__.py | 1 + .../components/ezviz/binary_sensor.py | 4 - homeassistant/components/ezviz/manifest.json | 2 +- homeassistant/components/ezviz/update.py | 109 ++++++++++++++++++ requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 7 files changed, 114 insertions(+), 7 deletions(-) create mode 100644 homeassistant/components/ezviz/update.py diff --git a/.coveragerc b/.coveragerc index 6f3b1b092c..792e0c3785 100644 --- a/.coveragerc +++ b/.coveragerc @@ -330,6 +330,7 @@ omit = homeassistant/components/ezviz/entity.py homeassistant/components/ezviz/sensor.py homeassistant/components/ezviz/switch.py + homeassistant/components/ezviz/update.py homeassistant/components/faa_delays/__init__.py homeassistant/components/faa_delays/binary_sensor.py homeassistant/components/familyhub/camera.py diff --git a/homeassistant/components/ezviz/__init__.py b/homeassistant/components/ezviz/__init__.py index 489ff97eb4..05857abbac 100644 --- a/homeassistant/components/ezviz/__init__.py +++ b/homeassistant/components/ezviz/__init__.py @@ -37,6 +37,7 @@ PLATFORMS_BY_TYPE: dict[str, list] = { Platform.CAMERA, Platform.SENSOR, Platform.SWITCH, + Platform.UPDATE, ], } diff --git a/homeassistant/components/ezviz/binary_sensor.py b/homeassistant/components/ezviz/binary_sensor.py index bab6fa5ca9..77e95fa221 100644 --- a/homeassistant/components/ezviz/binary_sensor.py +++ b/homeassistant/components/ezviz/binary_sensor.py @@ -25,10 +25,6 @@ BINARY_SENSOR_TYPES: dict[str, BinarySensorEntityDescription] = { key="alarm_schedules_enabled" ), "encrypted": BinarySensorEntityDescription(key="encrypted"), - "upgrade_available": BinarySensorEntityDescription( - key="upgrade_available", - device_class=BinarySensorDeviceClass.UPDATE, - ), } diff --git a/homeassistant/components/ezviz/manifest.json b/homeassistant/components/ezviz/manifest.json index 5dfeae4bae..219f4c87d1 100644 --- a/homeassistant/components/ezviz/manifest.json +++ b/homeassistant/components/ezviz/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/ezviz", "iot_class": "cloud_polling", "loggers": ["paho_mqtt", "pyezviz"], - "requirements": ["pyezviz==0.2.0.9"] + "requirements": ["pyezviz==0.2.0.12"] } diff --git a/homeassistant/components/ezviz/update.py b/homeassistant/components/ezviz/update.py new file mode 100644 index 0000000000..3acc103251 --- /dev/null +++ b/homeassistant/components/ezviz/update.py @@ -0,0 +1,109 @@ +"""Support for EZVIZ sensors.""" +from __future__ import annotations + +from typing import Any + +from pyezviz import HTTPError, PyEzvizError + +from homeassistant.components.update import ( + UpdateDeviceClass, + UpdateEntity, + UpdateEntityDescription, + UpdateEntityFeature, +) +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from .const import DATA_COORDINATOR, DOMAIN +from .coordinator import EzvizDataUpdateCoordinator +from .entity import EzvizEntity + +PARALLEL_UPDATES = 1 + +UPDATE_ENTITY_TYPES = UpdateEntityDescription( + key="version", + name="Firmware update", + device_class=UpdateDeviceClass.FIRMWARE, +) + + +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback +) -> None: + """Set up EZVIZ sensors based on a config entry.""" + coordinator: EzvizDataUpdateCoordinator = hass.data[DOMAIN][entry.entry_id][ + DATA_COORDINATOR + ] + + async_add_entities( + EzvizUpdateEntity(coordinator, camera, sensor, UPDATE_ENTITY_TYPES) + for camera in coordinator.data + for sensor, value in coordinator.data[camera].items() + if sensor in UPDATE_ENTITY_TYPES.key + if value + ) + + +class EzvizUpdateEntity(EzvizEntity, UpdateEntity): + """Representation of a EZVIZ Update entity.""" + + _attr_has_entity_name = True + _attr_supported_features = ( + UpdateEntityFeature.INSTALL + | UpdateEntityFeature.PROGRESS + | UpdateEntityFeature.RELEASE_NOTES + ) + + def __init__( + self, + coordinator: EzvizDataUpdateCoordinator, + serial: str, + sensor: str, + description: UpdateEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator, serial) + self._attr_unique_id = f"{serial}_{sensor}" + self.entity_description = description + + @property + def installed_version(self) -> str | None: + """Version installed and in use.""" + return self.data["version"] + + @property + def in_progress(self) -> bool | int | None: + """Update installation progress.""" + if self.data["upgrade_in_progress"]: + return self.data["upgrade_percent"] + return False + + @property + def latest_version(self) -> str | None: + """Latest version available for install.""" + if self.data["upgrade_available"]: + return self.data["latest_firmware_info"]["version"] + + return self.installed_version + + def release_notes(self) -> str | None: + """Return full release notes.""" + if self.data["latest_firmware_info"]: + return self.data["latest_firmware_info"].get("desc") + return None + + async def async_install( + self, version: str | None, backup: bool, **kwargs: Any + ) -> None: + """Install an update.""" + try: + await self.hass.async_add_executor_job( + self.coordinator.ezviz_client.upgrade_device, self._serial + ) + + except (HTTPError, PyEzvizError) as err: + raise HomeAssistantError( + f"Failed to update firmware on {self.name}" + ) from err diff --git a/requirements_all.txt b/requirements_all.txt index 414aff976a..28ac5cc662 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1626,7 +1626,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.0.9 +pyezviz==0.2.0.12 # homeassistant.components.fibaro pyfibaro==0.7.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7370d2b45f..b21e7dca4f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1187,7 +1187,7 @@ pyeverlights==0.1.0 pyevilgenius==2.0.0 # homeassistant.components.ezviz -pyezviz==0.2.0.9 +pyezviz==0.2.0.12 # homeassistant.components.fibaro pyfibaro==0.7.1 From bb8dfc94703d9795501dcbc99b2ad5da54699cc5 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 May 2023 06:16:20 -0500 Subject: [PATCH 13/15] Bump protobuf to 4.23.1 (#93338) --- homeassistant/package_constraints.txt | 2 +- script/gen_requirements_all.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 0654587d24..9f0e6e79d9 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -154,7 +154,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.22.3 +protobuf==4.23.1 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder diff --git a/script/gen_requirements_all.py b/script/gen_requirements_all.py index 594c78c81a..43aff2566f 100755 --- a/script/gen_requirements_all.py +++ b/script/gen_requirements_all.py @@ -158,7 +158,7 @@ pyOpenSSL>=23.1.0 # protobuf must be in package constraints for the wheel # builder to build binary wheels -protobuf==4.22.3 +protobuf==4.23.1 # faust-cchardet: Ensure we have a version we can build wheels # 2.1.18 is the first version that works with our wheel builder From 28fa6f541f99981ffba0369a1cb0da885fa76766 Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Tue, 23 May 2023 07:18:26 -0500 Subject: [PATCH 14/15] Bump ifaddr to 0.2.0 (#93377) --- homeassistant/components/network/manifest.json | 2 +- homeassistant/components/network/models.py | 2 +- homeassistant/components/network/util.py | 3 +++ homeassistant/package_constraints.txt | 2 +- pyproject.toml | 2 +- requirements.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 8 files changed, 10 insertions(+), 7 deletions(-) diff --git a/homeassistant/components/network/manifest.json b/homeassistant/components/network/manifest.json index f7e0bea242..a76da88914 100644 --- a/homeassistant/components/network/manifest.json +++ b/homeassistant/components/network/manifest.json @@ -7,5 +7,5 @@ "integration_type": "system", "iot_class": "local_push", "quality_scale": "internal", - "requirements": ["ifaddr==0.1.7"] + "requirements": ["ifaddr==0.2.0"] } diff --git a/homeassistant/components/network/models.py b/homeassistant/components/network/models.py index d3fbc82448..4428578f8f 100644 --- a/homeassistant/components/network/models.py +++ b/homeassistant/components/network/models.py @@ -24,7 +24,7 @@ class Adapter(TypedDict): """Configured network adapters.""" name: str - index: int + index: int | None enabled: bool auto: bool default: bool diff --git a/homeassistant/components/network/util.py b/homeassistant/components/network/util.py index 6f204b0539..2fb0690684 100644 --- a/homeassistant/components/network/util.py +++ b/homeassistant/components/network/util.py @@ -97,6 +97,7 @@ def _ifaddr_adapter_to_ha( ip_addr = ip_address(ip_config.ip[0]) ip_v6s.append(_ip_v6_from_adapter(ip_config)) else: + assert not isinstance(ip_config.ip, tuple) ip_addr = ip_address(ip_config.ip) ip_v4s.append(_ip_v4_from_adapter(ip_config)) @@ -117,6 +118,7 @@ def _ifaddr_adapter_to_ha( def _ip_v6_from_adapter(ip_config: ifaddr.IP) -> IPv6ConfiguredAddress: + assert isinstance(ip_config.ip, tuple) return { "address": ip_config.ip[0], "flowinfo": ip_config.ip[1], @@ -126,6 +128,7 @@ def _ip_v6_from_adapter(ip_config: ifaddr.IP) -> IPv6ConfiguredAddress: def _ip_v4_from_adapter(ip_config: ifaddr.IP) -> IPv4ConfiguredAddress: + assert not isinstance(ip_config.ip, tuple) return { "address": ip_config.ip, "network_prefix": ip_config.network_prefix, diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 9f0e6e79d9..a13a8f8479 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -28,7 +28,7 @@ home-assistant-bluetooth==1.10.0 home-assistant-frontend==20230503.3 home-assistant-intents==2023.4.26 httpx==0.24.0 -ifaddr==0.1.7 +ifaddr==0.2.0 janus==1.0.0 jinja2==3.1.2 lru-dict==1.1.8 diff --git a/pyproject.toml b/pyproject.toml index 1e69404f4c..1bda10f067 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -36,7 +36,7 @@ dependencies = [ # httpcore, anyio, and h11 in gen_requirements_all "httpx==0.24.0", "home-assistant-bluetooth==1.10.0", - "ifaddr==0.1.7", + "ifaddr==0.2.0", "jinja2==3.1.2", "lru-dict==1.1.8", "PyJWT==2.7.0", diff --git a/requirements.txt b/requirements.txt index 12533b5184..ca8bd26315 100644 --- a/requirements.txt +++ b/requirements.txt @@ -12,7 +12,7 @@ certifi>=2021.5.30 ciso8601==2.3.0 httpx==0.24.0 home-assistant-bluetooth==1.10.0 -ifaddr==0.1.7 +ifaddr==0.2.0 jinja2==3.1.2 lru-dict==1.1.8 PyJWT==2.7.0 diff --git a/requirements_all.txt b/requirements_all.txt index 28ac5cc662..1c7e3e9661 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -965,7 +965,7 @@ ical==4.5.1 icmplib==3.0 # homeassistant.components.network -ifaddr==0.1.7 +ifaddr==0.2.0 # homeassistant.components.iglo iglo==1.2.7 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b21e7dca4f..cf4f88351a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -745,7 +745,7 @@ ical==4.5.1 icmplib==3.0 # homeassistant.components.network -ifaddr==0.1.7 +ifaddr==0.2.0 # homeassistant.components.influxdb influxdb-client==1.24.0 From f8f83906f84c5be2870b7ff1bab378640bc57805 Mon Sep 17 00:00:00 2001 From: rikroe <42204099+rikroe@users.noreply.github.com> Date: Tue, 23 May 2023 15:21:29 +0200 Subject: [PATCH 15/15] Add climate on/off for supported BMW vehicles (#92962) * Add switch platform * Add tests * Remove separate button * Bump coverage --------- Co-authored-by: rikroe --- .../bmw_connected_drive/__init__.py | 1 + .../components/bmw_connected_drive/button.py | 6 - .../components/bmw_connected_drive/switch.py | 109 ++++++++++++++++++ .../snapshots/test_switch.ambr | 17 +++ .../bmw_connected_drive/test_switch.py | 100 ++++++++++++++++ 5 files changed, 227 insertions(+), 6 deletions(-) create mode 100644 homeassistant/components/bmw_connected_drive/switch.py create mode 100644 tests/components/bmw_connected_drive/snapshots/test_switch.ambr create mode 100644 tests/components/bmw_connected_drive/test_switch.py diff --git a/homeassistant/components/bmw_connected_drive/__init__.py b/homeassistant/components/bmw_connected_drive/__init__.py index 8d5d842e91..27f2d99cd2 100644 --- a/homeassistant/components/bmw_connected_drive/__init__.py +++ b/homeassistant/components/bmw_connected_drive/__init__.py @@ -44,6 +44,7 @@ PLATFORMS = [ Platform.NUMBER, Platform.SELECT, Platform.SENSOR, + Platform.SWITCH, ] SERVICE_UPDATE_STATE = "update_state" diff --git a/homeassistant/components/bmw_connected_drive/button.py b/homeassistant/components/bmw_connected_drive/button.py index 873a72762a..0ecc07357f 100644 --- a/homeassistant/components/bmw_connected_drive/button.py +++ b/homeassistant/components/bmw_connected_drive/button.py @@ -53,12 +53,6 @@ BUTTON_TYPES: tuple[BMWButtonEntityDescription, ...] = ( name="Activate air conditioning", remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning(), ), - BMWButtonEntityDescription( - key="deactivate_air_conditioning", - icon="mdi:hvac-off", - name="Deactivate air conditioning", - remote_function=lambda vehicle: vehicle.remote_services.trigger_remote_air_conditioning_stop(), - ), BMWButtonEntityDescription( key="find_vehicle", icon="mdi:crosshairs-question", diff --git a/homeassistant/components/bmw_connected_drive/switch.py b/homeassistant/components/bmw_connected_drive/switch.py new file mode 100644 index 0000000000..af7a42b35b --- /dev/null +++ b/homeassistant/components/bmw_connected_drive/switch.py @@ -0,0 +1,109 @@ +"""Switch platform for BMW.""" + +from collections.abc import Callable, Coroutine +from dataclasses import dataclass +import logging +from typing import Any + +from bimmer_connected.models import MyBMWAPIError +from bimmer_connected.vehicle import MyBMWVehicle + +from homeassistant.components.switch import SwitchEntity, SwitchEntityDescription +from homeassistant.config_entries import ConfigEntry +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.entity_platform import AddEntitiesCallback + +from . import BMWBaseEntity +from .const import DOMAIN +from .coordinator import BMWDataUpdateCoordinator + +_LOGGER = logging.getLogger(__name__) + + +@dataclass +class BMWRequiredKeysMixin: + """Mixin for required keys.""" + + value_fn: Callable[[MyBMWVehicle], bool] + remote_service_on: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] + remote_service_off: Callable[[MyBMWVehicle], Coroutine[Any, Any, Any]] + + +@dataclass +class BMWSwitchEntityDescription(SwitchEntityDescription, BMWRequiredKeysMixin): + """Describes BMW switch entity.""" + + is_available: Callable[[MyBMWVehicle], bool] = lambda _: False + dynamic_options: Callable[[MyBMWVehicle], list[str]] | None = None + + +NUMBER_TYPES: list[BMWSwitchEntityDescription] = [ + BMWSwitchEntityDescription( + key="climate", + name="Climate", + is_available=lambda v: v.is_remote_climate_stop_enabled, + value_fn=lambda v: v.climate.is_climate_on, + remote_service_on=lambda v: v.remote_services.trigger_remote_air_conditioning(), + remote_service_off=lambda v: v.remote_services.trigger_remote_air_conditioning_stop(), + icon="mdi:fan", + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: ConfigEntry, + async_add_entities: AddEntitiesCallback, +) -> None: + """Set up the MyBMW switch from config entry.""" + coordinator: BMWDataUpdateCoordinator = hass.data[DOMAIN][config_entry.entry_id] + + entities: list[BMWSwitch] = [] + + for vehicle in coordinator.account.vehicles: + if not coordinator.read_only: + entities.extend( + [ + BMWSwitch(coordinator, vehicle, description) + for description in NUMBER_TYPES + if description.is_available(vehicle) + ] + ) + async_add_entities(entities) + + +class BMWSwitch(BMWBaseEntity, SwitchEntity): + """Representation of BMW Switch entity.""" + + entity_description: BMWSwitchEntityDescription + + def __init__( + self, + coordinator: BMWDataUpdateCoordinator, + vehicle: MyBMWVehicle, + description: BMWSwitchEntityDescription, + ) -> None: + """Initialize an BMW Switch.""" + super().__init__(coordinator, vehicle) + self.entity_description = description + self._attr_unique_id = f"{vehicle.vin}-{description.key}" + + @property + def is_on(self) -> bool: + """Return the entity value to represent the entity state.""" + return self.entity_description.value_fn(self.vehicle) + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn the switch on.""" + try: + await self.entity_description.remote_service_on(self.vehicle) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn the switch off.""" + try: + await self.entity_description.remote_service_off(self.vehicle) + except MyBMWAPIError as ex: + raise HomeAssistantError(ex) from ex diff --git a/tests/components/bmw_connected_drive/snapshots/test_switch.ambr b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr new file mode 100644 index 0000000000..23d7ec2e83 --- /dev/null +++ b/tests/components/bmw_connected_drive/snapshots/test_switch.ambr @@ -0,0 +1,17 @@ +# serializer version: 1 +# name: test_entity_state_attrs + list([ + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'attribution': 'Data provided by MyBMW', + 'friendly_name': 'i4 eDrive40 Climate', + 'icon': 'mdi:fan', + }), + 'context': , + 'entity_id': 'switch.i4_edrive40_climate', + 'last_changed': , + 'last_updated': , + 'state': 'off', + }), + ]) +# --- diff --git a/tests/components/bmw_connected_drive/test_switch.py b/tests/components/bmw_connected_drive/test_switch.py new file mode 100644 index 0000000000..fd9871dfec --- /dev/null +++ b/tests/components/bmw_connected_drive/test_switch.py @@ -0,0 +1,100 @@ +"""Test BMW switches.""" +from unittest.mock import AsyncMock + +from bimmer_connected.models import MyBMWAPIError, MyBMWRemoteServiceError +from bimmer_connected.vehicle.remote_services import RemoteServices +import pytest +import respx +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError + +from . import setup_mocked_integration + + +async def test_entity_state_attrs( + hass: HomeAssistant, + bmw_fixture: respx.Router, + snapshot: SnapshotAssertion, +) -> None: + """Test switch options and values..""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Get all switch entities + assert hass.states.async_all("switch") == snapshot + + +@pytest.mark.parametrize( + ("entity_id", "value"), + [ + ("switch.i4_edrive40_climate", "ON"), + ("switch.i4_edrive40_climate", "OFF"), + ], +) +async def test_update_triggers_success( + hass: HomeAssistant, + entity_id: str, + value: str, + bmw_fixture: respx.Router, +) -> None: + """Test allowed values for switch inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Test + await hass.services.async_call( + "switch", + f"turn_{value.lower()}", + blocking=True, + target={"entity_id": entity_id}, + ) + assert RemoteServices.trigger_remote_service.call_count == 1 + + +@pytest.mark.parametrize( + ("raised", "expected"), + [ + (MyBMWRemoteServiceError, HomeAssistantError), + (MyBMWAPIError, HomeAssistantError), + (ValueError, ValueError), + ], +) +async def test_update_triggers_exceptions( + hass: HomeAssistant, + raised: Exception, + expected: Exception, + bmw_fixture: respx.Router, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test not allowed values for switch inputs.""" + + # Setup component + assert await setup_mocked_integration(hass) + + # Setup exception + monkeypatch.setattr( + RemoteServices, + "trigger_remote_service", + AsyncMock(side_effect=raised), + ) + + # Test + with pytest.raises(expected): + await hass.services.async_call( + "switch", + "turn_on", + blocking=True, + target={"entity_id": "switch.i4_edrive40_climate"}, + ) + with pytest.raises(expected): + await hass.services.async_call( + "switch", + "turn_off", + blocking=True, + target={"entity_id": "switch.i4_edrive40_climate"}, + ) + assert RemoteServices.trigger_remote_service.call_count == 2