diff --git a/homeassistant/components/fressnapf_tracker/light.py b/homeassistant/components/fressnapf_tracker/light.py index fc3c58445b3fe7..363a41ad1ae358 100644 --- a/homeassistant/components/fressnapf_tracker/light.py +++ b/homeassistant/components/fressnapf_tracker/light.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING, Any +from fressnapftracker import FressnapfTrackerError + from homeassistant.components.light import ( ATTR_BRIGHTNESS, ColorMode, @@ -16,6 +18,7 @@ from . import FressnapfTrackerConfigEntry from .const import DOMAIN from .entity import FressnapfTrackerEntity +from .services import handle_fressnapf_tracker_exception PARALLEL_UPDATES = 1 @@ -61,12 +64,18 @@ async def async_turn_on(self, **kwargs: Any) -> None: self.raise_if_not_activatable() brightness = kwargs.get(ATTR_BRIGHTNESS, 255) brightness = int((brightness / 255) * 100) - await self.coordinator.client.set_led_brightness(brightness) + try: + await self.coordinator.client.set_led_brightness(brightness) + except FressnapfTrackerError as e: + handle_fressnapf_tracker_exception(e) await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the device.""" - await self.coordinator.client.set_led_brightness(0) + try: + await self.coordinator.client.set_led_brightness(0) + except FressnapfTrackerError as e: + handle_fressnapf_tracker_exception(e) await self.coordinator.async_request_refresh() def raise_if_not_activatable(self) -> None: diff --git a/homeassistant/components/fressnapf_tracker/quality_scale.yaml b/homeassistant/components/fressnapf_tracker/quality_scale.yaml index f4d24e577c7f3c..39614e94b66b50 100644 --- a/homeassistant/components/fressnapf_tracker/quality_scale.yaml +++ b/homeassistant/components/fressnapf_tracker/quality_scale.yaml @@ -26,7 +26,7 @@ rules: unique-config-entry: done # Silver - action-exceptions: todo + action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done diff --git a/homeassistant/components/fressnapf_tracker/services.py b/homeassistant/components/fressnapf_tracker/services.py new file mode 100644 index 00000000000000..267a25c1cf6fc0 --- /dev/null +++ b/homeassistant/components/fressnapf_tracker/services.py @@ -0,0 +1,21 @@ +"""Services and service helpers for fressnapf_tracker.""" + +from fressnapftracker import FressnapfTrackerError, FressnapfTrackerInvalidTokenError + +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError + +from .const import DOMAIN + + +def handle_fressnapf_tracker_exception(exception: FressnapfTrackerError): + """Handle the different FressnapfTracker errors.""" + if isinstance(exception, FressnapfTrackerInvalidTokenError): + raise ConfigEntryAuthFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + ) from exception + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="api_error", + translation_placeholders={"error_message": str(exception)}, + ) from exception diff --git a/homeassistant/components/fressnapf_tracker/strings.json b/homeassistant/components/fressnapf_tracker/strings.json index 2cc88af8a8fa09..73be1adb376e81 100644 --- a/homeassistant/components/fressnapf_tracker/strings.json +++ b/homeassistant/components/fressnapf_tracker/strings.json @@ -77,6 +77,9 @@ } }, "exceptions": { + "api_error": { + "message": "An error occurred while communicating with the Fressnapf Tracker API: {error_message}" + }, "charging": { "message": "The flashlight cannot be activated while charging." }, diff --git a/homeassistant/components/fressnapf_tracker/switch.py b/homeassistant/components/fressnapf_tracker/switch.py index aebc7eb4873c30..5b2d52e60ddbd6 100644 --- a/homeassistant/components/fressnapf_tracker/switch.py +++ b/homeassistant/components/fressnapf_tracker/switch.py @@ -2,6 +2,8 @@ from typing import TYPE_CHECKING, Any +from fressnapftracker import FressnapfTrackerError + from homeassistant.components.switch import ( SwitchDeviceClass, SwitchEntity, @@ -13,6 +15,7 @@ from . import FressnapfTrackerConfigEntry from .entity import FressnapfTrackerEntity +from .services import handle_fressnapf_tracker_exception PARALLEL_UPDATES = 1 @@ -43,12 +46,18 @@ class FressnapfTrackerSwitch(FressnapfTrackerEntity, SwitchEntity): async def async_turn_on(self, **kwargs: Any) -> None: """Turn on the device.""" - await self.coordinator.client.set_energy_saving(True) + try: + await self.coordinator.client.set_energy_saving(True) + except FressnapfTrackerError as e: + handle_fressnapf_tracker_exception(e) await self.coordinator.async_request_refresh() async def async_turn_off(self, **kwargs: Any) -> None: """Turn off the device.""" - await self.coordinator.client.set_energy_saving(False) + try: + await self.coordinator.client.set_energy_saving(False) + except FressnapfTrackerError as e: + handle_fressnapf_tracker_exception(e) await self.coordinator.async_request_refresh() @property diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index 8b4acff9073345..0e2d0afb7c4508 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["nextdns"], "quality_scale": "platinum", - "requirements": ["nextdns==4.1.0"] + "requirements": ["nextdns==5.0.0"] } diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index ad7c2b76619a34..fa15be5a5a1010 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -7,5 +7,5 @@ "integration_type": "hub", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["pyportainer==1.0.21"] + "requirements": ["pyportainer==1.0.22"] } diff --git a/homeassistant/components/shelly/climate.py b/homeassistant/components/shelly/climate.py index d369720bc309b9..39bcaf711ab92c 100644 --- a/homeassistant/components/shelly/climate.py +++ b/homeassistant/components/shelly/climate.py @@ -442,7 +442,7 @@ def __init__( self._attr_device_info = get_entity_block_device_info(coordinator, sensor_block) self._attr_name = None # Main device entity - self._channel = cast(int, self._unique_id.split("_")[1]) + self._channel = int(self._unique_id.split("_")[1]) @property def extra_restore_state_data(self) -> ShellyClimateExtraStoredData: @@ -543,8 +543,9 @@ async def set_state_full_path(self, **kwargs: Any) -> Any: """Set block state (HTTP request).""" LOGGER.debug("Setting state for entity %s, state: %s", self.name, kwargs) try: - return await self.coordinator.device.http_request( - "get", f"thermostat/{self._channel}", kwargs + return await self.coordinator.device.set_thermostat_state( + self._channel, + **kwargs, ) except DeviceConnectionError as err: self.coordinator.last_update_success = False @@ -577,7 +578,7 @@ async def async_set_temperature(self, **kwargs: Any) -> None: UnitOfTemperature.FAHRENHEIT, ) - await self.set_state_full_path(target_t_enabled=1, target_t=f"{target_temp}") + await self.set_state_full_path(target_t_enabled=1, target_t=target_temp) async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set hvac mode.""" @@ -585,7 +586,7 @@ async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: if isinstance(self.target_temperature, float): self._last_target_temp = self.target_temperature await self.set_state_full_path( - target_t_enabled=1, target_t=f"{self._attr_min_temp}" + target_t_enabled=1, target_t=self._attr_min_temp ) if hvac_mode == HVACMode.HEAT: await self.set_state_full_path( @@ -599,9 +600,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: if preset_index == 0: await self.set_state_full_path(schedule=0) else: - await self.set_state_full_path( - schedule=1, schedule_profile=f"{preset_index}" - ) + await self.set_state_full_path(schedule=1, schedule_profile=preset_index) async def async_added_to_hass(self) -> None: """Handle entity which will be added.""" diff --git a/homeassistant/components/teslemetry/select.py b/homeassistant/components/teslemetry/select.py index fec54b75880ec3..9139feb9818745 100644 --- a/homeassistant/components/teslemetry/select.py +++ b/homeassistant/components/teslemetry/select.py @@ -330,7 +330,9 @@ async def async_select_option(self, option: str) -> None: self.async_write_ha_state() -class TeslemetryExportRuleSelectEntity(TeslemetryEnergyInfoEntity, SelectEntity): +class TeslemetryExportRuleSelectEntity( + TeslemetryEnergyInfoEntity, SelectEntity, RestoreEntity +): """Select entity for export rules select entities.""" _attr_options: list[str] = [ @@ -348,9 +350,28 @@ def __init__( self.scoped = Scope.ENERGY_CMDS in scopes super().__init__(data, "components_customer_preferred_export_rule") + async def async_added_to_hass(self) -> None: + """Handle entity which will be added.""" + await super().async_added_to_hass() + + # Restore state if it's not known + if self._attr_current_option is None: + if (state := await self.async_get_last_state()) is not None: + if state.state in self._attr_options: + self._attr_current_option = state.state + def _async_update_attrs(self) -> None: """Update the attributes of the entity.""" - self._attr_current_option = self.get(self.key, EnergyExportMode.NEVER.value) + if value := self._value: + # Customer selected export option + self._attr_current_option = value + elif self.get("components_non_export_configured") is True: + # In VPP, Export is disabled + self._attr_current_option = EnergyExportMode.NEVER + elif self._attr_current_option == EnergyExportMode.NEVER: + # In VPP, Export is enabled, but our state shows it is disabled + self._attr_current_option = None # Unknown + # In VPP Mode, Export isn't disabled, so use last known state async def async_select_option(self, option: str) -> None: """Change the selected option.""" diff --git a/homeassistant/components/tplink_omada/config_flow.py b/homeassistant/components/tplink_omada/config_flow.py index 6fec7d30381324..debd7832a3dc6b 100644 --- a/homeassistant/components/tplink_omada/config_flow.py +++ b/homeassistant/components/tplink_omada/config_flow.py @@ -177,6 +177,10 @@ async def async_step_reauth_confirm( info = await self._test_login(self._omada_opts, errors) if info is not None: + # Check the controller ID is the same as before + await self.async_set_unique_id(info.controller_id) + self._abort_if_unique_id_mismatch(reason="device_mismatch") + # Auth successful - update the config entry with the new credentials return self.async_update_reload_and_abort( self._get_reauth_entry(), data=self._omada_opts diff --git a/homeassistant/components/tplink_omada/quality_scale.yaml b/homeassistant/components/tplink_omada/quality_scale.yaml index 00d92dae3eae63..59e859854c3246 100644 --- a/homeassistant/components/tplink_omada/quality_scale.yaml +++ b/homeassistant/components/tplink_omada/quality_scale.yaml @@ -6,9 +6,7 @@ rules: comment: Service data APIs are polled every 5 minutes brands: done common-modules: done - config-flow-test-coverage: - status: todo - comment: "test_form_single_site is patching config flow internals, and should only patch external APIs. Must address feedback from #156697." + config-flow-test-coverage: done config-flow: done dependency-transparency: status: done diff --git a/homeassistant/components/tplink_omada/strings.json b/homeassistant/components/tplink_omada/strings.json index 4e4c6a19ea6109..968b36a1c59cca 100644 --- a/homeassistant/components/tplink_omada/strings.json +++ b/homeassistant/components/tplink_omada/strings.json @@ -2,6 +2,7 @@ "config": { "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_device%]", + "device_mismatch": "Please ensure you reauthenticate the same controller.", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" }, "error": { diff --git a/homeassistant/components/velbus/entity.py b/homeassistant/components/velbus/entity.py index aa221b3ca1078c..dcf6b65c80c138 100644 --- a/homeassistant/components/velbus/entity.py +++ b/homeassistant/components/velbus/entity.py @@ -69,6 +69,11 @@ async def _on_update(self) -> None: """Handle status updates from the channel.""" self.async_write_ha_state() + @property + def available(self) -> bool: + """Return if entity is available.""" + return self._channel.is_connected() + def api_call[_T: VelbusEntity, **_P]( func: Callable[Concatenate[_T, _P], Awaitable[None]], diff --git a/homeassistant/components/velbus/manifest.json b/homeassistant/components/velbus/manifest.json index 4270a40a6d7b77..886a9049ce3ef2 100644 --- a/homeassistant/components/velbus/manifest.json +++ b/homeassistant/components/velbus/manifest.json @@ -13,7 +13,7 @@ "velbus-packet", "velbus-protocol" ], - "quality_scale": "bronze", + "quality_scale": "silver", "requirements": ["velbus-aio==2026.1.1"], "usb": [ { diff --git a/homeassistant/components/velbus/quality_scale.yaml b/homeassistant/components/velbus/quality_scale.yaml index 81907f50c35850..85bd351ff80742 100644 --- a/homeassistant/components/velbus/quality_scale.yaml +++ b/homeassistant/components/velbus/quality_scale.yaml @@ -21,13 +21,12 @@ rules: test-before-configure: done test-before-setup: done unique-config-entry: done - # Silver action-exceptions: done config-entry-unloading: done docs-configuration-parameters: done docs-installation-parameters: done - entity-unavailable: todo + entity-unavailable: done integration-owner: done log-when-unavailable: done parallel-updates: done diff --git a/homeassistant/components/velux/manifest.json b/homeassistant/components/velux/manifest.json index e6ab6089b7143a..4790eadaa85c80 100644 --- a/homeassistant/components/velux/manifest.json +++ b/homeassistant/components/velux/manifest.json @@ -13,5 +13,5 @@ "integration_type": "hub", "iot_class": "local_polling", "loggers": ["pyvlx"], - "requirements": ["pyvlx==0.2.26"] + "requirements": ["pyvlx==0.2.27"] } diff --git a/requirements_all.txt b/requirements_all.txt index 3a3f0ebf63abbd..706e44cf7afd36 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1572,7 +1572,7 @@ nextcloudmonitor==1.5.1 nextcord==3.1.0 # homeassistant.components.nextdns -nextdns==4.1.0 +nextdns==5.0.0 # homeassistant.components.niko_home_control nhc==0.7.0 @@ -2318,7 +2318,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.21 +pyportainer==1.0.22 # homeassistant.components.probe_plus pyprobeplus==1.1.2 @@ -2657,7 +2657,7 @@ pyvesync==3.3.3 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.26 +pyvlx==0.2.27 # homeassistant.components.volumio pyvolumio==0.1.5 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 3fc867258b357f..eaec6c42f50620 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1367,7 +1367,7 @@ nextcloudmonitor==1.5.1 nextcord==3.1.0 # homeassistant.components.nextdns -nextdns==4.1.0 +nextdns==5.0.0 # homeassistant.components.niko_home_control nhc==0.7.0 @@ -1959,7 +1959,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.21 +pyportainer==1.0.22 # homeassistant.components.probe_plus pyprobeplus==1.1.2 @@ -2229,7 +2229,7 @@ pyvesync==3.3.3 pyvizio==0.1.61 # homeassistant.components.velux -pyvlx==0.2.26 +pyvlx==0.2.27 # homeassistant.components.volumio pyvolumio==0.1.5 diff --git a/tests/components/fressnapf_tracker/conftest.py b/tests/components/fressnapf_tracker/conftest.py index 022490205e06e0..8e32084b94a418 100644 --- a/tests/components/fressnapf_tracker/conftest.py +++ b/tests/components/fressnapf_tracker/conftest.py @@ -33,34 +33,6 @@ MOCK_ACCESS_TOKEN = "mock_access_token" MOCK_SERIAL_NUMBER = "ABC123456" MOCK_DEVICE_TOKEN = "mock_device_token" -MOCK_TRACKER = Tracker( - name="Fluffy", - battery=85, - charging=False, - position=Position( - lat=52.520008, - lng=13.404954, - accuracy=10, - timestamp="2024-01-15T12:00:00Z", - ), - tracker_settings=TrackerSettings( - generation="GPS Tracker 2.0", - features=TrackerFeatures( - flash_light=True, energy_saving_mode=True, live_tracking=True - ), - ), - led_brightness=LedBrightness(status="ok", value=50), - energy_saving=EnergySaving(status="ok", value=1), - deep_sleep=None, - led_activatable=LedActivatable( - has_led=True, - seen_recently=True, - nonempty_battery=True, - not_charging=True, - overall=True, - ), - icon="http://res.cloudinary.com/iot-venture/image/upload/v1717594357/kyaqq7nfitrdvaoakb8s.jpg", -) @pytest.fixture @@ -136,7 +108,36 @@ def mock_api_client() -> Generator[MagicMock]: "homeassistant.components.fressnapf_tracker.coordinator.ApiClient" ) as mock_api_client: client = mock_api_client.return_value - client.get_tracker = AsyncMock(return_value=MOCK_TRACKER) + client.get_tracker = AsyncMock( + return_value=Tracker( + name="Fluffy", + battery=85, + charging=False, + position=Position( + lat=52.520008, + lng=13.404954, + accuracy=10, + timestamp="2024-01-15T12:00:00Z", + ), + tracker_settings=TrackerSettings( + generation="GPS Tracker 2.0", + features=TrackerFeatures( + flash_light=True, energy_saving_mode=True, live_tracking=True + ), + ), + led_brightness=LedBrightness(status="ok", value=50), + energy_saving=EnergySaving(status="ok", value=1), + deep_sleep=None, + led_activatable=LedActivatable( + has_led=True, + seen_recently=True, + nonempty_battery=True, + not_charging=True, + overall=True, + ), + icon="http://res.cloudinary.com/iot-venture/image/upload/v1717594357/kyaqq7nfitrdvaoakb8s.jpg", + ) + ) client.set_led_brightness = AsyncMock(return_value=None) client.set_energy_saving = AsyncMock(return_value=None) yield client diff --git a/tests/components/fressnapf_tracker/test_light.py b/tests/components/fressnapf_tracker/test_light.py index e124429e367b1f..5c2ba227d61a43 100644 --- a/tests/components/fressnapf_tracker/test_light.py +++ b/tests/components/fressnapf_tracker/test_light.py @@ -3,7 +3,13 @@ from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch -from fressnapftracker import Tracker, TrackerFeatures, TrackerSettings +from fressnapftracker import ( + FressnapfTrackerError, + FressnapfTrackerInvalidTokenError, + Tracker, + TrackerFeatures, + TrackerSettings, +) import pytest from syrupy.assertion import SnapshotAssertion @@ -15,7 +21,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, Platform from homeassistant.core import HomeAssistant -from homeassistant.exceptions import HomeAssistantError +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, snapshot_platform @@ -172,3 +178,41 @@ async def test_turn_on_led_not_activatable( ) mock_api_client.set_led_brightness.assert_not_called() + + +@pytest.mark.parametrize( + ("api_exception", "expected_exception"), + [ + (FressnapfTrackerError("Something went wrong"), HomeAssistantError), + ( + FressnapfTrackerInvalidTokenError("Token no longer valid"), + ConfigEntryAuthFailed, + ), + ], +) +@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF]) +@pytest.mark.usefixtures("mock_auth_client") +async def test_turn_on_off_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_client: MagicMock, + api_exception: FressnapfTrackerError, + expected_exception: type[HomeAssistantError], + service: str, +) -> None: + """Test that errors during service handling are handled correctly.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "light.fluffy_flashlight" + + mock_api_client.set_led_brightness.side_effect = api_exception + with pytest.raises(expected_exception): + await hass.services.async_call( + LIGHT_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/fressnapf_tracker/test_switch.py b/tests/components/fressnapf_tracker/test_switch.py index 59b2cbb62db63c..d64384d3cbf274 100644 --- a/tests/components/fressnapf_tracker/test_switch.py +++ b/tests/components/fressnapf_tracker/test_switch.py @@ -3,7 +3,13 @@ from collections.abc import AsyncGenerator from unittest.mock import MagicMock, patch -from fressnapftracker import Tracker, TrackerFeatures, TrackerSettings +from fressnapftracker import ( + FressnapfTrackerError, + FressnapfTrackerInvalidTokenError, + Tracker, + TrackerFeatures, + TrackerSettings, +) import pytest from syrupy.assertion import SnapshotAssertion @@ -14,6 +20,7 @@ ) from homeassistant.const import ATTR_ENTITY_ID, STATE_ON, Platform from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed, HomeAssistantError from homeassistant.helpers import entity_registry as er from tests.common import MockConfigEntry, snapshot_platform @@ -112,3 +119,41 @@ async def test_turn_off( ) mock_api_client.set_energy_saving.assert_called_once_with(False) + + +@pytest.mark.parametrize( + ("api_exception", "expected_exception"), + [ + (FressnapfTrackerError("Something went wrong"), HomeAssistantError), + ( + FressnapfTrackerInvalidTokenError("Token no longer valid"), + ConfigEntryAuthFailed, + ), + ], +) +@pytest.mark.parametrize("service", [SERVICE_TURN_ON, SERVICE_TURN_OFF]) +@pytest.mark.usefixtures("mock_auth_client") +async def test_turn_on_off_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_api_client: MagicMock, + api_exception: FressnapfTrackerError, + expected_exception: type[HomeAssistantError], + service: str, +) -> None: + """Test that errors during service handling are handled correctly.""" + + mock_config_entry.add_to_hass(hass) + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "switch.fluffy_sleep_mode" + + mock_api_client.set_energy_saving.side_effect = api_exception + with pytest.raises(expected_exception): + await hass.services.async_call( + SWITCH_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id}, + blocking=True, + ) diff --git a/tests/components/shelly/test_climate.py b/tests/components/shelly/test_climate.py index c5ac132809b73f..2a220e496bef25 100644 --- a/tests/components/shelly/test_climate.py +++ b/tests/components/shelly/test_climate.py @@ -118,8 +118,8 @@ async def test_climate_hvac_mode( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) - mock_block_device.http_request.assert_called_once_with( - "get", "thermostat/0", {"target_t_enabled": 1, "target_t": 20.0} + mock_block_device.set_thermostat_state.assert_called_once_with( + 0, target_t_enabled=1, target_t=20.0 ) monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 20.0) @@ -136,8 +136,8 @@ async def test_climate_hvac_mode( blocking=True, ) - mock_block_device.http_request.assert_called_with( - "get", "thermostat/0", {"target_t_enabled": 1, "target_t": "4"} + mock_block_device.set_thermostat_state.assert_called_with( + 0, target_t_enabled=1, target_t=4.0 ) monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 4.0) @@ -177,10 +177,10 @@ async def test_climate_set_temperature( blocking=True, ) - mock_block_device.http_request.assert_called_once_with( - "get", "thermostat/0", {"target_t_enabled": 1, "target_t": "23.0"} + mock_block_device.set_thermostat_state.assert_called_once_with( + 0, target_t_enabled=1, target_t=23.0 ) - mock_block_device.http_request.reset_mock() + mock_block_device.set_thermostat_state.reset_mock() # Test conversion from C to F monkeypatch.setattr( @@ -200,8 +200,8 @@ async def test_climate_set_temperature( blocking=True, ) - mock_block_device.http_request.assert_called_once_with( - "get", "thermostat/0", {"target_t_enabled": 1, "target_t": "68.0"} + mock_block_device.set_thermostat_state.assert_called_once_with( + 0, target_t_enabled=1, target_t=68.0 ) @@ -230,8 +230,8 @@ async def test_climate_set_preset_mode( blocking=True, ) - mock_block_device.http_request.assert_called_once_with( - "get", "thermostat/0", {"schedule": 1, "schedule_profile": "2"} + mock_block_device.set_thermostat_state.assert_called_once_with( + 0, schedule=1, schedule_profile=2 ) monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "mode", 2) @@ -248,10 +248,8 @@ async def test_climate_set_preset_mode( blocking=True, ) - assert len(mock_block_device.http_request.mock_calls) == 2 - mock_block_device.http_request.assert_called_with( - "get", "thermostat/0", {"schedule": 0} - ) + assert len(mock_block_device.set_thermostat_state.mock_calls) == 2 + mock_block_device.set_thermostat_state.assert_called_with(0, schedule=0) monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "mode", 0) mock_block_device.mock_update() @@ -319,8 +317,8 @@ async def test_block_restored_climate( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) - mock_block_device.http_request.assert_called_once_with( - "get", "thermostat/0", {"target_t_enabled": 1, "target_t": 22.0} + mock_block_device.set_thermostat_state.assert_called_once_with( + 0, target_t_enabled=1, target_t=22.0 ) monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 22.0) @@ -396,8 +394,8 @@ async def test_block_restored_climate_us_customary( {ATTR_ENTITY_ID: ENTITY_ID, ATTR_HVAC_MODE: HVACMode.HEAT}, blocking=True, ) - mock_block_device.http_request.assert_called_once_with( - "get", "thermostat/0", {"target_t_enabled": 1, "target_t": 10.0} + mock_block_device.set_thermostat_state.assert_called_once_with( + 0, target_t_enabled=1, target_t=10.0 ) monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "targetTemp", 10.0) @@ -483,7 +481,7 @@ async def test_block_set_mode_connection_error( monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.setattr( mock_block_device, - "http_request", + "set_thermostat_state", AsyncMock(side_effect=DeviceConnectionError), ) await init_integration(hass, 1, sleep_period=1000) @@ -511,7 +509,7 @@ async def test_block_set_mode_auth_error( monkeypatch.setattr(mock_block_device.blocks[DEVICE_BLOCK_ID], "valveError", 0) monkeypatch.setattr( mock_block_device, - "http_request", + "set_thermostat_state", AsyncMock(side_effect=InvalidAuthError), ) entry = await init_integration(hass, 1, sleep_period=1000) diff --git a/tests/components/teslemetry/test_select.py b/tests/components/teslemetry/test_select.py index b17b52903fa5a0..aa1acb177787a1 100644 --- a/tests/components/teslemetry/test_select.py +++ b/tests/components/teslemetry/test_select.py @@ -1,7 +1,9 @@ """Test the Teslemetry select platform.""" +from copy import deepcopy from unittest.mock import AsyncMock, patch +from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion from tesla_fleet_api.const import EnergyExportMode, EnergyOperationMode @@ -12,13 +14,16 @@ DOMAIN as SELECT_DOMAIN, SERVICE_SELECT_OPTION, ) +from homeassistant.components.teslemetry.coordinator import ENERGY_INFO_INTERVAL from homeassistant.components.teslemetry.select import LOW from homeassistant.const import ATTR_ENTITY_ID, STATE_UNKNOWN, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er from . import assert_entities, reload_platform, setup_platform -from .const import COMMAND_OK, VEHICLE_DATA_ALT +from .const import COMMAND_OK, SITE_INFO, VEHICLE_DATA_ALT + +from tests.common import async_fire_time_changed @pytest.mark.usefixtures("entity_registry_enabled_by_default") @@ -164,3 +169,135 @@ async def test_select_streaming( ): state = hass.states.get(entity_id) assert state.state == snapshot(name=entity_id) + + +async def test_export_rule_restore( + hass: HomeAssistant, + mock_site_info: AsyncMock, +) -> None: + """Test export rule entity when value is missing due to VPP enrollment.""" + # Mock energy site with missing export rule (VPP scenario) + vpp_site_info = deepcopy(SITE_INFO) + # Remove the customer_preferred_export_rule to simulate VPP enrollment + del vpp_site_info["response"]["components"]["customer_preferred_export_rule"] + mock_site_info.side_effect = lambda: vpp_site_info + + # Set up platform + entry = await setup_platform(hass, [Platform.SELECT]) + + # Entity should exist but have no current option initially + entity_id = "select.energy_site_allow_export" + state = hass.states.get(entity_id) + assert state is not None + assert state.state == STATE_UNKNOWN + + # Test service call works even when value is missing (VPP enrolled) + with patch( + "tesla_fleet_api.teslemetry.EnergySite.grid_import_export", + return_value=COMMAND_OK, + ) as call: + await hass.services.async_call( + SELECT_DOMAIN, + SERVICE_SELECT_OPTION, + { + ATTR_ENTITY_ID: entity_id, + ATTR_OPTION: EnergyExportMode.BATTERY_OK.value, + }, + blocking=True, + ) + state = hass.states.get(entity_id) + assert state.state == EnergyExportMode.BATTERY_OK.value + call.assert_called_once() + + # Reload the platform to test state restoration + await reload_platform(hass, entry, [Platform.SELECT]) + + # The entity should restore the previous state since API value is still missing + state = hass.states.get(entity_id) + assert state.state == EnergyExportMode.BATTERY_OK.value + + +@pytest.mark.parametrize( + ("previous_data", "new_data", "expected_state"), + [ + # Path 1: Customer selected export option (has value) + ( + { + "customer_preferred_export_rule": "battery_ok", + "non_export_configured": None, + }, + { + "customer_preferred_export_rule": "pv_only", + "non_export_configured": None, + }, + EnergyExportMode.PV_ONLY.value, + ), + # Path 2: In VPP, Export is disabled (non_export_configured is True) + ( + { + "customer_preferred_export_rule": "battery_ok", + "non_export_configured": None, + }, + { + "customer_preferred_export_rule": None, + "non_export_configured": True, + }, + EnergyExportMode.NEVER.value, + ), + # Path 3: In VPP, Export enabled but state shows disabled (current_option is NEVER) + ( + { + "customer_preferred_export_rule": "never", + "non_export_configured": None, + }, + { + "customer_preferred_export_rule": None, + "non_export_configured": None, + }, + STATE_UNKNOWN, + ), + # Path 4: In VPP Mode, Export isn't disabled, use last known state + ( + { + "customer_preferred_export_rule": "battery_ok", + "non_export_configured": None, + }, + { + "customer_preferred_export_rule": None, + "non_export_configured": None, + }, + EnergyExportMode.BATTERY_OK.value, + ), + ], +) +async def test_export_rule_update_attrs_logic( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_site_info: AsyncMock, + previous_data: dict, + new_data: str | None, + expected_state: str, +) -> None: + """Test all logic paths in TeslemetryExportRuleSelectEntity._async_update_attrs.""" + # Create site info with the test data + test_site_info = deepcopy(SITE_INFO) + test_site_info["response"]["components"].update(previous_data) + mock_site_info.side_effect = lambda: test_site_info + + # Set up platform + await setup_platform(hass, [Platform.SELECT]) + + # Change the state + test_site_info = deepcopy(SITE_INFO) + test_site_info["response"]["components"].update(new_data) + mock_site_info.side_effect = lambda: test_site_info + + # Coordinator refresh + freezer.tick(ENERGY_INFO_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + # Check the final state matches expected + state = hass.states.get("select.energy_site_allow_export") + assert state + assert state.state == expected_state diff --git a/tests/components/tplink_omada/conftest.py b/tests/components/tplink_omada/conftest.py index 6aeb4040ed2afd..07eb636ccad2b4 100644 --- a/tests/components/tplink_omada/conftest.py +++ b/tests/components/tplink_omada/conftest.py @@ -6,6 +6,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest +from tplink_omada_client import OmadaSite from tplink_omada_client.clients import ( OmadaConnectedClient, OmadaNetworkClient, @@ -154,14 +155,22 @@ async def _get_mock_client(hass: HomeAssistant, mac: str) -> OmadaNetworkClient: @pytest.fixture def mock_omada_client(mock_omada_site_client: AsyncMock) -> Generator[MagicMock]: """Mock Omada client.""" - with patch( - "homeassistant.components.tplink_omada.create_omada_client", - autospec=True, - ) as client_mock: + with ( + patch( + "homeassistant.components.tplink_omada.create_omada_client", + autospec=True, + ) as client_mock, + patch( + "homeassistant.components.tplink_omada.config_flow.create_omada_client", + new=client_mock, + ), + ): client = client_mock.return_value client.get_site_client.return_value = mock_omada_site_client - client.login = AsyncMock() + client.login.return_value = "12345" + client.get_controller_name.return_value = "OC200" + client.get_sites.return_value = [OmadaSite("Display Name", "SiteId")] yield client diff --git a/tests/components/tplink_omada/test_config_flow.py b/tests/components/tplink_omada/test_config_flow.py index 28ef0da170f810..13035603aea97a 100644 --- a/tests/components/tplink_omada/test_config_flow.py +++ b/tests/components/tplink_omada/test_config_flow.py @@ -1,7 +1,8 @@ """Test the TP-Link Omada config flows.""" -from unittest.mock import patch +from unittest.mock import MagicMock, patch +import pytest from tplink_omada_client import OmadaSite from tplink_omada_client.exceptions import ( ConnectionFailed, @@ -10,13 +11,10 @@ UnsupportedControllerVersion, ) -from homeassistant import config_entries -from homeassistant.components.tplink_omada.config_flow import ( - HubInfo, - _validate_input, - create_omada_client, -) +from homeassistant.components.tplink_omada.config_flow import create_omada_client from homeassistant.components.tplink_omada.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType @@ -38,86 +36,66 @@ } -async def test_form_single_site(hass: HomeAssistant) -> None: +async def test_form_single_site( + hass: HomeAssistant, + mock_omada_client: MagicMock, + mock_setup_entry: MagicMock, +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {} - with ( - patch( - "homeassistant.components.tplink_omada.config_flow._validate_input", - return_value=HubInfo( - "omada_id", "OC200", [OmadaSite("Display Name", "SiteId")] - ), - ) as mocked_validate, - patch( - "homeassistant.components.tplink_omada.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_DATA, - ) - await hass.async_block_till_done() + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "OC200 (Display Name)" - assert result2["data"] == MOCK_ENTRY_DATA + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OC200 (Display Name)" + assert result["data"] == MOCK_ENTRY_DATA + assert result["result"].unique_id == "12345" assert len(mock_setup_entry.mock_calls) == 1 - mocked_validate.assert_called_once_with(hass, MOCK_USER_DATA) -async def test_form_multiple_sites(hass: HomeAssistant) -> None: +async def test_form_multiple_sites( + hass: HomeAssistant, + mock_omada_client: MagicMock, + mock_setup_entry: MagicMock, +) -> None: """Test we get the form.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "user" assert result["errors"] == {} - with ( - patch( - "homeassistant.components.tplink_omada.config_flow._validate_input", - return_value=HubInfo( - "omada_id", - "OC200", - [OmadaSite("Site 1", "first"), OmadaSite("Site 2", "second")], - ), - ), - patch( - "homeassistant.components.tplink_omada.async_setup_entry", - return_value=True, - ) as mock_setup_entry, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_DATA, - ) - await hass.async_block_till_done() + mock_omada_client.get_sites.return_value = [ + OmadaSite("Site 1", "first"), + OmadaSite("Site 2", "second"), + ] - assert result2["type"] is FlowResultType.FORM - assert result2["step_id"] == "site" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, + ) - with patch( - "homeassistant.components.tplink_omada.async_setup_entry", - return_value=True, - ) as mock_setup_entry: - result3 = await hass.config_entries.flow.async_configure( - result["flow_id"], - { - "site": "second", - }, - ) - await hass.async_block_till_done() + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "site" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "site": "second", + }, + ) - assert result3["type"] is FlowResultType.CREATE_ENTRY - assert result3["title"] == "OC200 (Site 2)" - assert result3["data"] == { + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OC200 (Site 2)" + assert result["data"] == { "host": "https://fake.omada.host", "verify_ssl": True, "site": "second", @@ -127,213 +105,154 @@ async def test_form_multiple_sites(hass: HomeAssistant) -> None: assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_invalid_auth(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (LoginFailed(-1000, "Invalid username/password"), "invalid_auth"), + (OmadaClientException(), "unknown"), + (Exception("Generic error"), "unknown"), + (UnsupportedControllerVersion("4.0.0"), "unsupported_controller"), + (ConnectionFailed(), "cannot_connect"), + ], +) +async def test_form_errors_and_recovery( + hass: HomeAssistant, + mock_omada_client: MagicMock, + mock_setup_entry: MagicMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test we handle various errors and can recover to complete the flow.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - with patch( - "homeassistant.components.tplink_omada.config_flow._validate_input", - side_effect=LoginFailed(-1000, "Invalid username/password"), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_DATA, - ) + # First attempt: trigger the error + mock_omada_client.login.side_effect = side_effect - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "invalid_auth"} - - -async def test_form_api_error(hass: HomeAssistant) -> None: - """Test we handle unknown API error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, ) - with patch( - "homeassistant.components.tplink_omada.config_flow._validate_input", - side_effect=OmadaClientException, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_DATA, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": expected_error} + # Second attempt: clear error and complete successfully + mock_omada_client.login.side_effect = None -async def test_form_generic_exception(hass: HomeAssistant) -> None: - """Test we handle unknown API error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, ) - with patch( - "homeassistant.components.tplink_omada.config_flow._validate_input", - side_effect=Exception, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_DATA, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unknown"} + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "OC200 (Display Name)" + assert result["data"] == MOCK_ENTRY_DATA + assert len(mock_setup_entry.mock_calls) == 1 -async def test_form_unsupported_controller(hass: HomeAssistant) -> None: - """Test we handle unknown API error.""" +async def test_form_no_sites(hass: HomeAssistant, mock_omada_client: MagicMock) -> None: + """Test we handle the case when no sites are found.""" result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + DOMAIN, context={"source": SOURCE_USER} ) - with patch( - "homeassistant.components.tplink_omada.config_flow._validate_input", - side_effect=UnsupportedControllerVersion("4.0.0"), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_DATA, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "unsupported_controller"} - + mock_omada_client.get_sites.return_value = [] -async def test_form_cannot_connect(hass: HomeAssistant) -> None: - """Test we handle cannot connect error.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, ) - with patch( - "homeassistant.components.tplink_omada.config_flow._validate_input", - side_effect=ConnectionFailed, - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_DATA, - ) - - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "cannot_connect"} + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": "no_sites_found"} + mock_omada_client.get_sites.return_value = [OmadaSite("Display Name", "SiteId")] -async def test_form_no_sites(hass: HomeAssistant) -> None: - """Test we handle invalid auth.""" - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + MOCK_USER_DATA, ) - with patch( - "homeassistant.components.tplink_omada.config_flow._validate_input", - return_value=HubInfo("omada_id", "OC200", []), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], - MOCK_USER_DATA, - ) + assert result["type"] is FlowResultType.CREATE_ENTRY - assert result2["type"] is FlowResultType.FORM - assert result2["errors"] == {"base": "no_sites_found"} +@pytest.mark.parametrize( + ("controller_id", "expected_reason"), + [ + ("12345", "reauth_successful"), + ("different_controller_id", "device_mismatch"), + ], +) +async def test_async_step_reauth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_omada_client: MagicMock, + controller_id: str, + expected_reason: str, +) -> None: + """Test reauth flow with matching and mismatching controller IDs.""" + mock_config_entry.add_to_hass(hass) -async def test_async_step_reauth_success(hass: HomeAssistant) -> None: - """Test reauth starts an interactive flow.""" - - mock_entry = MockConfigEntry( - domain="tplink_omada", - data=dict(MOCK_ENTRY_DATA), - unique_id="USERID", - ) - mock_entry.add_to_hass(hass) - - result = await mock_entry.start_reauth_flow(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with patch( - "homeassistant.components.tplink_omada.config_flow._validate_input", - return_value=HubInfo( - "omada_id", "OC200", [OmadaSite("Display Name", "SiteId")] - ), - ) as mocked_validate: - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {"username": "new_uname", "password": "new_passwd"} - ) - await hass.async_block_till_done() + mock_omada_client.login.return_value = controller_id - assert result2["type"] is FlowResultType.ABORT - assert result2["reason"] == "reauth_successful" - mocked_validate.assert_called_once_with( - hass, - { - "host": "https://fake.omada.host", - "verify_ssl": True, - "site": "SiteId", - "username": "new_uname", - "password": "new_passwd", - }, + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "new_uname", CONF_PASSWORD: "new_passwd"} ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == expected_reason -async def test_async_step_reauth_invalid_auth(hass: HomeAssistant) -> None: - """Test reauth starts an interactive flow.""" - mock_entry = MockConfigEntry( - domain="tplink_omada", - data=dict(MOCK_ENTRY_DATA), - unique_id="USERID", - ) - mock_entry.add_to_hass(hass) +@pytest.mark.parametrize( + ("side_effect", "expected_error"), + [ + (LoginFailed(-1000, "Invalid username/password"), "invalid_auth"), + (OmadaClientException(), "unknown"), + (Exception("Generic error"), "unknown"), + (UnsupportedControllerVersion("4.0.0"), "unsupported_controller"), + (ConnectionFailed(), "cannot_connect"), + ], +) +async def test_async_step_reauth_invalid_auth( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_omada_client: MagicMock, + side_effect: Exception, + expected_error: str, +) -> None: + """Test reauth handles various exceptions.""" + mock_config_entry.add_to_hass(hass) - result = await mock_entry.start_reauth_flow(hass) + result = await mock_config_entry.start_reauth_flow(hass) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - with patch( - "homeassistant.components.tplink_omada.config_flow._validate_input", - side_effect=LoginFailed(-1000, "Invalid username/password"), - ): - result2 = await hass.config_entries.flow.async_configure( - result["flow_id"], {"username": "new_uname", "password": "new_passwd"} - ) - await hass.async_block_till_done() + mock_omada_client.login.side_effect = side_effect + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "new_uname", CONF_PASSWORD: "new_passwd"} + ) assert result["type"] is FlowResultType.FORM assert result["step_id"] == "reauth_confirm" - assert result2["errors"] == {"base": "invalid_auth"} + assert result["errors"] == {"base": expected_error} + mock_omada_client.login.side_effect = None -async def test_validate_input(hass: HomeAssistant) -> None: - """Test validate returns HubInfo.""" + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {CONF_USERNAME: "new_uname", CONF_PASSWORD: "new_passwd"} + ) - with ( - patch( - "tplink_omada_client.omadaclient.OmadaClient", autospec=True - ) as mock_client, - patch( - "homeassistant.components.tplink_omada.config_flow.create_omada_client", - return_value=mock_client, - ) as create_mock, - ): - mock_client.login.return_value = "Id" - mock_client.get_controller_name.return_value = "Name" - mock_client.get_sites.return_value = [OmadaSite("x", "y")] - result = await _validate_input(hass, MOCK_USER_DATA) - - create_mock.assert_awaited_once() - mock_client.login.assert_awaited_once() - mock_client.get_controller_name.assert_awaited_once() - mock_client.get_sites.assert_awaited_once() - assert result.controller_id == "Id" - assert result.name == "Name" - assert result.sites == [OmadaSite("x", "y")] + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" async def test_create_omada_client_parses_args(hass: HomeAssistant) -> None: diff --git a/tests/components/velbus/snapshots/test_switch.ambr b/tests/components/velbus/snapshots/test_switch.ambr index 7eb886cdd7b74e..25551ed9071914 100644 --- a/tests/components/velbus/snapshots/test_switch.ambr +++ b/tests/components/velbus/snapshots/test_switch.ambr @@ -47,3 +47,51 @@ 'state': 'on', }) # --- +# name: test_switch_disabled[switch.living_room_relayname-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': None, + 'entity_id': 'switch.living_room_relayname', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': 'RelayName', + 'platform': 'velbus', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'qwerty123-55', + 'unit_of_measurement': None, + }) +# --- +# name: test_switch_disabled[switch.living_room_relayname-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Living room RelayName', + }), + 'context': , + 'entity_id': 'switch.living_room_relayname', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- diff --git a/tests/components/velbus/test_switch.py b/tests/components/velbus/test_switch.py index ebb1da084c4f0e..ab8127557b3869 100644 --- a/tests/components/velbus/test_switch.py +++ b/tests/components/velbus/test_switch.py @@ -32,6 +32,23 @@ async def test_entities( await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) +async def test_switch_disabled( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_relay: AsyncMock, + config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test disabled switch entity.""" + # make sure the valbuasio channel is_connected method returns false + mock_relay.is_connected.return_value = False + + with patch("homeassistant.components.velbus.PLATFORMS", [Platform.SWITCH]): + await init_integration(hass, config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + async def test_switch_on_off( hass: HomeAssistant, mock_relay: AsyncMock,