diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 10c01c492c4ecc..c2b863b55bea14 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -96,8 +96,14 @@ rules: - **coordinator.py**: Centralize data fetching logic ```python class MyCoordinator(DataUpdateCoordinator[MyData]): - def __init__(self, hass: HomeAssistant, client: MyClient) -> None: - super().__init__(hass, logger=LOGGER, name=DOMAIN, update_interval=timedelta(minutes=1)) + def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None: + super().__init__( + hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=1), + config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended + ) ``` - **entity.py**: Base entity definitions to reduce duplication ```python @@ -203,13 +209,24 @@ rules: - **Standard Pattern**: Use for efficient data management ```python class MyCoordinator(DataUpdateCoordinator): + def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None: + super().__init__( + hass, + logger=LOGGER, + name=DOMAIN, + update_interval=timedelta(minutes=5), + config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended + ) + self.client = client + async def _async_update_data(self): try: - return await self.api.fetch_data() + return await self.client.fetch_data() except ApiError as err: raise UpdateFailed(f"API communication error: {err}") ``` - **Error Types**: Use `UpdateFailed` for API errors, `ConfigEntryAuthFailed` for auth issues +- **Config Entry**: Always pass `config_entry` parameter to coordinator - it's accepted and recommended ## Integration Guidelines @@ -220,6 +237,10 @@ rules: - Connection-critical config: Store in `ConfigEntry.data` - Non-critical settings: Store in `ConfigEntry.options` - **Validation**: Always validate user input before creating entries +- **Config Entry Naming**: + - ❌ Do NOT allow users to set config entry names in config flows + - Names are automatically generated or can be customized later in UI + - ✅ Exception: Helper integrations MAY allow custom names in config flow - **Connection Testing**: Test device/service connection during config flow: ```python try: @@ -366,7 +387,8 @@ rules: ### Polling - Use update coordinator pattern when possible -- Polling intervals are NOT user-configurable +- **Polling intervals are NOT user-configurable**: Never add scan_interval, update_interval, or polling frequency options to config flows or config entries +- **Integration determines intervals**: Set `update_interval` programmatically based on integration logic, not user input - **Minimum Intervals**: - Local network: 5 seconds - Cloud services: 60 seconds @@ -384,6 +406,57 @@ rules: - `ConfigEntryNotReady`: Temporary setup issues (device offline) - `ConfigEntryAuthFailed`: Authentication problems - `ConfigEntryError`: Permanent setup issues +- **Try/Catch Best Practices**: + - Only wrap code that can throw exceptions + - Keep try blocks minimal - process data after the try/catch + - **Avoid bare exceptions** except in specific cases: + - ❌ Generally not allowed: `except:` or `except Exception:` + - ✅ Allowed in config flows to ensure robustness + - ✅ Allowed in functions/methods that run in background tasks + - Bad pattern: + ```python + try: + data = await device.get_data() # Can throw + # ❌ Don't process data inside try block + processed = data.get("value", 0) * 100 + self._attr_native_value = processed + except DeviceError: + _LOGGER.error("Failed to get data") + ``` + - Good pattern: + ```python + try: + data = await device.get_data() # Can throw + except DeviceError: + _LOGGER.error("Failed to get data") + return + + # ✅ Process data outside try block + processed = data.get("value", 0) * 100 + self._attr_native_value = processed + ``` +- **Bare Exception Usage**: + ```python + # ❌ Not allowed in regular code + try: + data = await device.get_data() + except Exception: # Too broad + _LOGGER.error("Failed") + + # ✅ Allowed in config flow for robustness + async def async_step_user(self, user_input=None): + try: + await self._test_connection(user_input) + except Exception: # Allowed here + errors["base"] = "unknown" + + # ✅ Allowed in background tasks + async def _background_refresh(): + try: + await coordinator.async_refresh() + except Exception: # Allowed in task + _LOGGER.exception("Unexpected error in background task") + ``` - **Setup Failure Patterns**: ```python try: @@ -445,6 +518,30 @@ rules: - Device names - Email addresses, usernames +### Entity Descriptions +- **Lambda/Anonymous Functions**: Often used in EntityDescription for value transformation +- **Multiline Lambdas**: When lambdas exceed line length, wrap in parentheses for readability +- **Bad pattern**: + ```python + SensorEntityDescription( + key="temperature", + name="Temperature", + value_fn=lambda data: round(data["temp_value"] * 1.8 + 32, 1) if data.get("temp_value") is not None else None, # ❌ Too long + ) + ``` +- **Good pattern**: + ```python + SensorEntityDescription( + key="temperature", + name="Temperature", + value_fn=lambda data: ( # ✅ Parenthesis on same line as lambda + round(data["temp_value"] * 1.8 + 32, 1) + if data.get("temp_value") is not None + else None + ), + ) + ``` + ### Entity Naming - **Use has_entity_name**: Set `_attr_has_entity_name = True` - **For specific fields**: @@ -846,6 +943,31 @@ return {"api_key": entry.data[CONF_API_KEY]} # ❌ Exposes secrets # Accessing hass.data directly in tests coordinator = hass.data[DOMAIN][entry.entry_id] # ❌ Don't access hass.data + +# User-configurable polling intervals +# In config flow +vol.Optional("scan_interval", default=60): cv.positive_int # ❌ Not allowed +# In coordinator +update_interval = timedelta(minutes=entry.data.get("scan_interval", 1)) # ❌ Not allowed + +# User-configurable config entry names (non-helper integrations) +vol.Optional("name", default="My Device"): cv.string # ❌ Not allowed in regular integrations + +# Too much code in try block +try: + response = await client.get_data() # Can throw + # ❌ Data processing should be outside try block + temperature = response["temperature"] / 10 + humidity = response["humidity"] + self._attr_native_value = temperature +except ClientError: + _LOGGER.error("Failed to fetch data") + +# Bare exceptions in regular code +try: + value = await sensor.read_value() +except Exception: # ❌ Too broad - catch specific exceptions + _LOGGER.error("Failed to read sensor") ``` ### ✅ **Use These Patterns Instead** @@ -875,6 +997,21 @@ return async_redact_data(data, {"api_key", "password"}) # ✅ Safe async def init_integration(hass, mock_config_entry, mock_api): mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) # ✅ Proper setup + +# Integration-determined polling intervals (not user-configurable) +SCAN_INTERVAL = timedelta(minutes=5) # ✅ Common pattern: constant in const.py + +class MyCoordinator(DataUpdateCoordinator[MyData]): + def __init__(self, hass: HomeAssistant, client: MyClient, config_entry: ConfigEntry) -> None: + # ✅ Integration determines interval based on device capabilities, connection type, etc. + interval = timedelta(minutes=1) if client.is_local else SCAN_INTERVAL + super().__init__( + hass, + logger=LOGGER, + name=DOMAIN, + update_interval=interval, + config_entry=config_entry, # ✅ Pass config_entry - it's accepted and recommended + ) ``` ### Entity Performance Optimization diff --git a/homeassistant/components/devolo_home_control/entity.py b/homeassistant/components/devolo_home_control/entity.py index dbe53c214121cf..9edc7d54145f34 100644 --- a/homeassistant/components/devolo_home_control/entity.py +++ b/homeassistant/components/devolo_home_control/entity.py @@ -9,7 +9,7 @@ from devolo_home_control_api.homecontrol import HomeControl from homeassistant.components.sensor import SensorDeviceClass -from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.entity import Entity from .const import DOMAIN @@ -35,7 +35,7 @@ def __init__( ) # This is not doing I/O. It fetches an internal state of the API self._attr_should_poll = False self._attr_unique_id = element_uid - self._attr_device_info = DeviceInfo( + self._attr_device_info = dr.DeviceInfo( configuration_url=f"https://{urlparse(device_instance.href).netloc}", identifiers={(DOMAIN, self._device_instance.uid)}, manufacturer=device_instance.brand, @@ -88,6 +88,16 @@ def _generic_message(self, message: tuple) -> None: elif len(message) == 3 and message[2] == "status": # Maybe the API wants to tell us, that the device went on- or offline. self._attr_available = self._device_instance.is_online() + elif message[1] == "del" and self.platform.config_entry: + device_registry = dr.async_get(self.hass) + device = device_registry.async_get_device( + identifiers={(DOMAIN, self._device_instance.uid)} + ) + if device: + device_registry.async_update_device( + device.id, + remove_config_entry_id=self.platform.config_entry.entry_id, + ) else: _LOGGER.debug("No valid message received: %s", message) diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 7519e0ae39471b..a5e35749e1b555 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -7,7 +7,10 @@ import logging from typing import Any -from ha_silabs_firmware_client import FirmwareUpdateClient +from aiohttp import ClientError +from ha_silabs_firmware_client import FirmwareUpdateClient, ManifestMissing +from universal_silabs_flasher.common import Version +from universal_silabs_flasher.firmware import NabuCasaMetadata from homeassistant.components.hassio import ( AddonError, @@ -149,15 +152,78 @@ async def _install_firmware_step( assert self._device is not None if not self.firmware_install_task: + # We 100% need to install new firmware only if the wrong firmware is + # currently installed + firmware_install_required = self._probed_firmware_info is None or ( + self._probed_firmware_info.firmware_type + != expected_installed_firmware_type + ) + session = async_get_clientsession(self.hass) client = FirmwareUpdateClient(fw_update_url, session) - manifest = await client.async_update_data() - fw_meta = next( - fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) - ) + try: + manifest = await client.async_update_data() + fw_manifest = next( + fw for fw in manifest.firmwares if fw.filename.startswith(fw_type) + ) + except (StopIteration, TimeoutError, ClientError, ManifestMissing) as err: + _LOGGER.warning( + "Failed to fetch firmware update manifest", exc_info=True + ) + + # Not having internet access should not prevent setup + if not firmware_install_required: + _LOGGER.debug( + "Skipping firmware upgrade due to index download failure" + ) + return self.async_show_progress_done(next_step_id=next_step_id) + + raise AbortFlow( + "fw_download_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": firmware_name, + }, + ) from err + + if not firmware_install_required: + assert self._probed_firmware_info is not None + + # Make sure we do not downgrade the firmware + fw_metadata = NabuCasaMetadata.from_json(fw_manifest.metadata) + fw_version = fw_metadata.get_public_version() + probed_fw_version = Version(self._probed_firmware_info.firmware_version) + + if probed_fw_version >= fw_version: + _LOGGER.debug( + "Not downgrading firmware, installed %s is newer than available %s", + probed_fw_version, + fw_version, + ) + return self.async_show_progress_done(next_step_id=next_step_id) + + try: + fw_data = await client.async_fetch_firmware(fw_manifest) + except (TimeoutError, ClientError, ValueError) as err: + _LOGGER.warning("Failed to fetch firmware update", exc_info=True) + + # If we cannot download new firmware, we shouldn't block setup + if not firmware_install_required: + _LOGGER.debug( + "Skipping firmware upgrade due to image download failure" + ) + return self.async_show_progress_done(next_step_id=next_step_id) + + # Otherwise, fail + raise AbortFlow( + "fw_download_failed", + description_placeholders={ + **self._get_translation_placeholders(), + "firmware_name": firmware_name, + }, + ) from err - fw_data = await client.async_fetch_firmware(fw_meta) self.firmware_install_task = self.hass.async_create_task( async_flash_silabs_firmware( hass=self.hass, @@ -215,6 +281,14 @@ async def async_step_addon_operation_failed( }, ) + async def async_step_pre_confirm_zigbee( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Pre-confirm Zigbee setup.""" + + # This step is necessary to prevent `user_input` from being passed through + return await self.async_step_confirm_zigbee() + async def async_step_confirm_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -409,7 +483,15 @@ async def async_step_start_otbr_addon( finally: self.addon_start_task = None - return self.async_show_progress_done(next_step_id="confirm_otbr") + return self.async_show_progress_done(next_step_id="pre_confirm_otbr") + + async def async_step_pre_confirm_otbr( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Pre-confirm OTBR setup.""" + + # This step is necessary to prevent `user_input` from being passed through + return await self.async_step_confirm_otbr() async def async_step_confirm_otbr( self, user_input: dict[str, Any] | None = None diff --git a/homeassistant/components/homeassistant_hardware/strings.json b/homeassistant/components/homeassistant_hardware/strings.json index 99172c963b8836..d9c086cb0400f7 100644 --- a/homeassistant/components/homeassistant_hardware/strings.json +++ b/homeassistant/components/homeassistant_hardware/strings.json @@ -36,7 +36,8 @@ "otbr_addon_already_running": "The OpenThread Border Router add-on is already running, it cannot be installed again.", "zha_still_using_stick": "This {model} is in use by the Zigbee Home Automation integration. Please migrate your Zigbee network to another adapter or delete the integration and try again.", "otbr_still_using_stick": "This {model} is in use by the OpenThread Border Router add-on. If you use the Thread network, make sure you have alternative border routers. Uninstall the add-on and try again.", - "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device." + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device. If you are running Home Assistant OS in a virtual machine or in Docker, please make sure that permissions are set correctly for the device.", + "fw_download_failed": "{firmware_name} firmware for your {model} failed to download. Make sure Home Assistant has internet access and try again." }, "progress": { "install_firmware": "Please wait while {firmware_name} firmware is installed to your {model}, this will take a few minutes. Do not make any changes to your hardware or software until this finishes." diff --git a/homeassistant/components/homeassistant_sky_connect/config_flow.py b/homeassistant/components/homeassistant_sky_connect/config_flow.py index 997edb54b18d92..197cb2ff2cee5b 100644 --- a/homeassistant/components/homeassistant_sky_connect/config_flow.py +++ b/homeassistant/components/homeassistant_sky_connect/config_flow.py @@ -93,7 +93,7 @@ async def async_step_install_zigbee_firmware( firmware_name="Zigbee", expected_installed_firmware_type=ApplicationType.EZSP, step_id="install_zigbee_firmware", - next_step_id="confirm_zigbee", + next_step_id="pre_confirm_zigbee", ) async def async_step_install_thread_firmware( diff --git a/homeassistant/components/homeassistant_sky_connect/strings.json b/homeassistant/components/homeassistant_sky_connect/strings.json index 08c8a56c30d166..f87a45febe4a46 100644 --- a/homeassistant/components/homeassistant_sky_connect/strings.json +++ b/homeassistant/components/homeassistant_sky_connect/strings.json @@ -92,7 +92,8 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", - "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]" + "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", @@ -145,7 +146,8 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", - "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]" + "unsupported_firmware": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::unsupported_firmware%]", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/homeassistant/components/homeassistant_yellow/strings.json b/homeassistant/components/homeassistant_yellow/strings.json index 980052f9ffbfcf..b43f890b4e3c4e 100644 --- a/homeassistant/components/homeassistant_yellow/strings.json +++ b/homeassistant/components/homeassistant_yellow/strings.json @@ -117,7 +117,8 @@ "otbr_addon_already_running": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_addon_already_running%]", "zha_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::zha_still_using_stick%]", "otbr_still_using_stick": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::otbr_still_using_stick%]", - "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or addon is currently trying to communicate with the device." + "unsupported_firmware": "The radio firmware on your {model} could not be determined. Make sure that no other integration or add-on is currently trying to communicate with the device.", + "fw_download_failed": "[%key:component::homeassistant_hardware::firmware_picker::options::abort::fw_download_failed%]" }, "progress": { "install_addon": "[%key:component::homeassistant_hardware::silabs_multiprotocol_hardware::options::progress::install_addon%]", diff --git a/homeassistant/components/iron_os/manifest.json b/homeassistant/components/iron_os/manifest.json index 58cbdaa3bc6744..be2309ab340568 100644 --- a/homeassistant/components/iron_os/manifest.json +++ b/homeassistant/components/iron_os/manifest.json @@ -14,5 +14,5 @@ "iot_class": "local_polling", "loggers": ["pynecil"], "quality_scale": "platinum", - "requirements": ["pynecil==4.1.0"] + "requirements": ["pynecil==4.1.1"] } diff --git a/homeassistant/components/jellyfin/client_wrapper.py b/homeassistant/components/jellyfin/client_wrapper.py index 91fe0885e4cb3b..4855231184e48a 100644 --- a/homeassistant/components/jellyfin/client_wrapper.py +++ b/homeassistant/components/jellyfin/client_wrapper.py @@ -66,8 +66,7 @@ def _connect_to_address( ) -> dict[str, Any]: """Connect to the Jellyfin server.""" result: dict[str, Any] = connection_manager.connect_to_address(url) - - if result["State"] != CONNECTION_STATE["ServerSignIn"]: + if CONNECTION_STATE(result["State"]) != CONNECTION_STATE.ServerSignIn: raise CannotConnect return result diff --git a/homeassistant/components/jellyfin/coordinator.py b/homeassistant/components/jellyfin/coordinator.py index cd22ad4ab3962f..30149453ba3619 100644 --- a/homeassistant/components/jellyfin/coordinator.py +++ b/homeassistant/components/jellyfin/coordinator.py @@ -54,6 +54,9 @@ async def _async_update_data(self) -> dict[str, dict[str, Any]]: self.api_client.jellyfin.sessions ) + if sessions is None: + return {} + sessions_by_id: dict[str, dict[str, Any]] = { session["Id"]: session for session in sessions diff --git a/homeassistant/components/jellyfin/manifest.json b/homeassistant/components/jellyfin/manifest.json index d6b2261acaaa9e..839d9e685fcd82 100644 --- a/homeassistant/components/jellyfin/manifest.json +++ b/homeassistant/components/jellyfin/manifest.json @@ -7,6 +7,5 @@ "integration_type": "service", "iot_class": "local_polling", "loggers": ["jellyfin_apiclient_python"], - "requirements": ["jellyfin-apiclient-python==1.10.0"], - "single_config_entry": true + "requirements": ["jellyfin-apiclient-python==1.11.0"] } diff --git a/homeassistant/components/lamarzocco/entity.py b/homeassistant/components/lamarzocco/entity.py index 6dc024645ce15e..6f9de083286c1a 100644 --- a/homeassistant/components/lamarzocco/entity.py +++ b/homeassistant/components/lamarzocco/entity.py @@ -2,8 +2,10 @@ from collections.abc import Callable from dataclasses import dataclass +from typing import cast -from pylamarzocco.const import FirmwareType +from pylamarzocco.const import FirmwareType, MachineState, WidgetType +from pylamarzocco.models import MachineStatus from homeassistant.const import CONF_ADDRESS, CONF_MAC from homeassistant.helpers.device_registry import ( @@ -32,6 +34,7 @@ class LaMarzoccoBaseEntity( """Common elements for all entities.""" _attr_has_entity_name = True + _unavailable_when_machine_off = True def __init__( self, @@ -63,6 +66,21 @@ def __init__( if connections: self._attr_device_info.update(DeviceInfo(connections=connections)) + @property + def available(self) -> bool: + """Return True if entity is available.""" + machine_state = ( + cast( + MachineStatus, + self.coordinator.device.dashboard.config[WidgetType.CM_MACHINE_STATUS], + ).status + if WidgetType.CM_MACHINE_STATUS in self.coordinator.device.dashboard.config + else MachineState.OFF + ) + return super().available and not ( + self._unavailable_when_machine_off and machine_state is MachineState.OFF + ) + class LaMarzoccoEntity(LaMarzoccoBaseEntity): """Common elements for all entities.""" diff --git a/homeassistant/components/lamarzocco/number.py b/homeassistant/components/lamarzocco/number.py index f8cb8b1d6feb1d..b235cc7c5f9a7d 100644 --- a/homeassistant/components/lamarzocco/number.py +++ b/homeassistant/components/lamarzocco/number.py @@ -58,10 +58,6 @@ class LaMarzoccoNumberEntityDescription( CoffeeBoiler, machine.dashboard.config[WidgetType.CM_COFFEE_BOILER] ).target_temperature ), - available_fn=( - lambda coordinator: WidgetType.CM_COFFEE_BOILER - in coordinator.device.dashboard.config - ), ), LaMarzoccoNumberEntityDescription( key="smart_standby_time", diff --git a/homeassistant/components/lamarzocco/sensor.py b/homeassistant/components/lamarzocco/sensor.py index c76f51c348838e..a432f5b8dae4ee 100644 --- a/homeassistant/components/lamarzocco/sensor.py +++ b/homeassistant/components/lamarzocco/sensor.py @@ -57,10 +57,6 @@ class LaMarzoccoSensorEntityDescription( ).ready_start_time ), entity_category=EntityCategory.DIAGNOSTIC, - available_fn=( - lambda coordinator: WidgetType.CM_COFFEE_BOILER - in coordinator.device.dashboard.config - ), ), LaMarzoccoSensorEntityDescription( key="steam_boiler_ready_time", @@ -188,6 +184,8 @@ def native_value(self) -> StateType | datetime | None: class LaMarzoccoStatisticSensorEntity(LaMarzoccoSensorEntity): """Sensor for La Marzocco statistics.""" + _unavailable_when_machine_off = False + @property def native_value(self) -> StateType | datetime | None: """Return the value of the sensor.""" diff --git a/homeassistant/components/nut/config_flow.py b/homeassistant/components/nut/config_flow.py index 69281e852a8b05..8a498b9968071c 100644 --- a/homeassistant/components/nut/config_flow.py +++ b/homeassistant/components/nut/config_flow.py @@ -39,10 +39,12 @@ def _base_schema( base_schema = { vol.Optional(CONF_HOST, default=nut_config.get(CONF_HOST) or DEFAULT_HOST): str, vol.Optional(CONF_PORT, default=nut_config.get(CONF_PORT) or DEFAULT_PORT): int, - vol.Optional(CONF_USERNAME, default=nut_config.get(CONF_USERNAME) or ""): str, + vol.Optional( + CONF_USERNAME, default=nut_config.get(CONF_USERNAME, vol.UNDEFINED) + ): str, vol.Optional( CONF_PASSWORD, - default=PASSWORD_NOT_CHANGED if use_password_not_changed else "", + default=PASSWORD_NOT_CHANGED if use_password_not_changed else vol.UNDEFINED, ): str, } diff --git a/homeassistant/components/subaru/strings.json b/homeassistant/components/subaru/strings.json index 6aef004187403c..e2399344544eaf 100644 --- a/homeassistant/components/subaru/strings.json +++ b/homeassistant/components/subaru/strings.json @@ -102,11 +102,11 @@ "services": { "unlock_specific_door": { "name": "Unlock specific door", - "description": "Unlocks specific door(s).", + "description": "Unlocks the driver door, all doors, or the tailgate.", "fields": { "door": { "name": "Door", - "description": "Which door(s) to open." + "description": "The specific door(s) to unlock." } } } diff --git a/homeassistant/components/telegram_bot/config_flow.py b/homeassistant/components/telegram_bot/config_flow.py index 1a77a5b9a81552..7b441889b8cdad 100644 --- a/homeassistant/components/telegram_bot/config_flow.py +++ b/homeassistant/components/telegram_bot/config_flow.py @@ -412,12 +412,20 @@ async def async_step_webhooks( """Handle config flow for webhook Telegram bot.""" if not user_input: + default_trusted_networks = ",".join( + [str(network) for network in DEFAULT_TRUSTED_NETWORKS] + ) + if self.source == SOURCE_RECONFIGURE: + suggested_values = dict(self._get_reconfigure_entry().data) + if CONF_TRUSTED_NETWORKS not in self._get_reconfigure_entry().data: + suggested_values[CONF_TRUSTED_NETWORKS] = default_trusted_networks + return self.async_show_form( step_id="webhooks", data_schema=self.add_suggested_values_to_schema( STEP_WEBHOOKS_DATA_SCHEMA, - self._get_reconfigure_entry().data, + suggested_values, ), ) @@ -426,9 +434,7 @@ async def async_step_webhooks( data_schema=self.add_suggested_values_to_schema( STEP_WEBHOOKS_DATA_SCHEMA, { - CONF_TRUSTED_NETWORKS: ",".join( - [str(network) for network in DEFAULT_TRUSTED_NETWORKS] - ), + CONF_TRUSTED_NETWORKS: default_trusted_networks, }, ), ) @@ -479,12 +485,8 @@ def _validate_webhooks( description_placeholders: dict[str, str], ) -> None: # validate URL - if CONF_URL in user_input and not user_input[CONF_URL].startswith("https"): - errors["base"] = "invalid_url" - description_placeholders[ERROR_FIELD] = "URL" - description_placeholders[ERROR_MESSAGE] = "URL must start with https" - return - if CONF_URL not in user_input: + url: str | None = user_input.get(CONF_URL) + if url is None: try: get_url(self.hass, require_ssl=True, allow_internal=False) except NoURLAvailableError: @@ -494,6 +496,11 @@ def _validate_webhooks( "URL is required since you have not configured an external URL in Home Assistant" ) return + elif not url.startswith("https"): + errors["base"] = "invalid_url" + description_placeholders[ERROR_FIELD] = "URL" + description_placeholders[ERROR_MESSAGE] = "URL must start with https" + return # validate trusted networks csv_trusted_networks: list[str] = [] diff --git a/homeassistant/components/whirlpool/binary_sensor.py b/homeassistant/components/whirlpool/binary_sensor.py index d8ec373f026336..d26f5764313c6b 100644 --- a/homeassistant/components/whirlpool/binary_sensor.py +++ b/homeassistant/components/whirlpool/binary_sensor.py @@ -42,14 +42,21 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config flow entry for Whirlpool binary sensors.""" - entities: list = [] appliances_manager = config_entry.runtime_data - for washer_dryer in appliances_manager.washer_dryers: - entities.extend( - WhirlpoolBinarySensor(washer_dryer, description) - for description in WASHER_DRYER_SENSORS - ) - async_add_entities(entities) + + washer_binary_sensors = [ + WhirlpoolBinarySensor(washer, description) + for washer in appliances_manager.washers + for description in WASHER_DRYER_SENSORS + ] + + dryer_binary_sensors = [ + WhirlpoolBinarySensor(dryer, description) + for dryer in appliances_manager.dryers + for description in WASHER_DRYER_SENSORS + ] + + async_add_entities([*washer_binary_sensors, *dryer_binary_sensors]) class WhirlpoolBinarySensor(WhirlpoolEntity, BinarySensorEntity): diff --git a/homeassistant/components/whirlpool/config_flow.py b/homeassistant/components/whirlpool/config_flow.py index 61d6883d70fc59..8c2161097312a8 100644 --- a/homeassistant/components/whirlpool/config_flow.py +++ b/homeassistant/components/whirlpool/config_flow.py @@ -70,7 +70,11 @@ async def authenticate( appliances_manager = AppliancesManager(backend_selector, auth, session) await appliances_manager.fetch_appliances() - if not appliances_manager.aircons and not appliances_manager.washer_dryers: + if ( + not appliances_manager.aircons + and not appliances_manager.washers + and not appliances_manager.dryers + ): return "no_appliances" return None diff --git a/homeassistant/components/whirlpool/diagnostics.py b/homeassistant/components/whirlpool/diagnostics.py index 09338396de4342..fed999b881cb3f 100644 --- a/homeassistant/components/whirlpool/diagnostics.py +++ b/homeassistant/components/whirlpool/diagnostics.py @@ -37,9 +37,13 @@ def get_appliance_diagnostics(appliance: Appliance) -> dict[str, Any]: appliances_manager = config_entry.runtime_data diagnostics_data = { - "washer_dryers": { - wd.name: get_appliance_diagnostics(wd) - for wd in appliances_manager.washer_dryers + "washers": { + washer.name: get_appliance_diagnostics(washer) + for washer in appliances_manager.washers + }, + "dryers": { + dryer.name: get_appliance_diagnostics(dryer) + for dryer in appliances_manager.dryers }, "aircons": { ac.name: get_appliance_diagnostics(ac) for ac in appliances_manager.aircons diff --git a/homeassistant/components/whirlpool/manifest.json b/homeassistant/components/whirlpool/manifest.json index 919fa54c834c25..2712e6b2f64129 100644 --- a/homeassistant/components/whirlpool/manifest.json +++ b/homeassistant/components/whirlpool/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["whirlpool"], "quality_scale": "bronze", - "requirements": ["whirlpool-sixth-sense==0.20.0"] + "requirements": ["whirlpool-sixth-sense==0.21.1"] } diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 6b052834656f02..164e1b6e5fe566 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -1,12 +1,14 @@ """The Washer/Dryer Sensor for Whirlpool Appliances.""" +from abc import ABC, abstractmethod from collections.abc import Callable from dataclasses import dataclass from datetime import datetime, timedelta from typing import override from whirlpool.appliance import Appliance -from whirlpool.washerdryer import MachineState, WasherDryer +from whirlpool.dryer import Dryer, MachineState as DryerMachineState +from whirlpool.washer import MachineState as WasherMachineState, Washer from homeassistant.components.sensor import ( RestoreSensor, @@ -33,26 +35,49 @@ 5: "active", } -WASHER_DRYER_MACHINE_STATE = { - MachineState.Standby: "standby", - MachineState.Setting: "setting", - MachineState.DelayCountdownMode: "delay_countdown", - MachineState.DelayPause: "delay_paused", - MachineState.SmartDelay: "smart_delay", - MachineState.SmartGridPause: "smart_grid_pause", - MachineState.Pause: "pause", - MachineState.RunningMainCycle: "running_maincycle", - MachineState.RunningPostCycle: "running_postcycle", - MachineState.Exceptions: "exception", - MachineState.Complete: "complete", - MachineState.PowerFailure: "power_failure", - MachineState.ServiceDiagnostic: "service_diagnostic_mode", - MachineState.FactoryDiagnostic: "factory_diagnostic_mode", - MachineState.LifeTest: "life_test", - MachineState.CustomerFocusMode: "customer_focus_mode", - MachineState.DemoMode: "demo_mode", - MachineState.HardStopOrError: "hard_stop_or_error", - MachineState.SystemInit: "system_initialize", +WASHER_MACHINE_STATE = { + WasherMachineState.Standby: "standby", + WasherMachineState.Setting: "setting", + WasherMachineState.DelayCountdownMode: "delay_countdown", + WasherMachineState.DelayPause: "delay_paused", + WasherMachineState.SmartDelay: "smart_delay", + WasherMachineState.SmartGridPause: "smart_grid_pause", + WasherMachineState.Pause: "pause", + WasherMachineState.RunningMainCycle: "running_maincycle", + WasherMachineState.RunningPostCycle: "running_postcycle", + WasherMachineState.Exceptions: "exception", + WasherMachineState.Complete: "complete", + WasherMachineState.PowerFailure: "power_failure", + WasherMachineState.ServiceDiagnostic: "service_diagnostic_mode", + WasherMachineState.FactoryDiagnostic: "factory_diagnostic_mode", + WasherMachineState.LifeTest: "life_test", + WasherMachineState.CustomerFocusMode: "customer_focus_mode", + WasherMachineState.DemoMode: "demo_mode", + WasherMachineState.HardStopOrError: "hard_stop_or_error", + WasherMachineState.SystemInit: "system_initialize", +} + +DRYER_MACHINE_STATE = { + DryerMachineState.Standby: "standby", + DryerMachineState.Setting: "setting", + DryerMachineState.DelayCountdownMode: "delay_countdown", + DryerMachineState.DelayPause: "delay_paused", + DryerMachineState.SmartDelay: "smart_delay", + DryerMachineState.SmartGridPause: "smart_grid_pause", + DryerMachineState.Pause: "pause", + DryerMachineState.RunningMainCycle: "running_maincycle", + DryerMachineState.RunningPostCycle: "running_postcycle", + DryerMachineState.Exceptions: "exception", + DryerMachineState.Complete: "complete", + DryerMachineState.PowerFailure: "power_failure", + DryerMachineState.ServiceDiagnostic: "service_diagnostic_mode", + DryerMachineState.FactoryDiagnostic: "factory_diagnostic_mode", + DryerMachineState.LifeTest: "life_test", + DryerMachineState.CustomerFocusMode: "customer_focus_mode", + DryerMachineState.DemoMode: "demo_mode", + DryerMachineState.HardStopOrError: "hard_stop_or_error", + DryerMachineState.SystemInit: "system_initialize", + DryerMachineState.Cancelled: "cancelled", } STATE_CYCLE_FILLING = "cycle_filling" @@ -64,29 +89,44 @@ STATE_DOOR_OPEN = "door_open" -def washer_dryer_state(washer_dryer: WasherDryer) -> str | None: - """Determine correct states for a washer/dryer.""" +def washer_state(washer: Washer) -> str | None: + """Determine correct states for a washer.""" - if washer_dryer.get_door_open(): + if washer.get_door_open(): return STATE_DOOR_OPEN - machine_state = washer_dryer.get_machine_state() + machine_state = washer.get_machine_state() - if machine_state == MachineState.RunningMainCycle: - if washer_dryer.get_cycle_status_filling(): + if machine_state == WasherMachineState.RunningMainCycle: + if washer.get_cycle_status_filling(): return STATE_CYCLE_FILLING - if washer_dryer.get_cycle_status_rinsing(): + if washer.get_cycle_status_rinsing(): return STATE_CYCLE_RINSING - if washer_dryer.get_cycle_status_sensing(): + if washer.get_cycle_status_sensing(): return STATE_CYCLE_SENSING - if washer_dryer.get_cycle_status_soaking(): + if washer.get_cycle_status_soaking(): return STATE_CYCLE_SOAKING - if washer_dryer.get_cycle_status_spinning(): + if washer.get_cycle_status_spinning(): return STATE_CYCLE_SPINNING - if washer_dryer.get_cycle_status_washing(): + if washer.get_cycle_status_washing(): return STATE_CYCLE_WASHING - return WASHER_DRYER_MACHINE_STATE.get(machine_state) + return WASHER_MACHINE_STATE.get(machine_state) + + +def dryer_state(dryer: Dryer) -> str | None: + """Determine correct states for a dryer.""" + + if dryer.get_door_open(): + return STATE_DOOR_OPEN + + machine_state = dryer.get_machine_state() + + if machine_state == DryerMachineState.RunningMainCycle: + if dryer.get_cycle_status_sensing(): + return STATE_CYCLE_SENSING + + return DRYER_MACHINE_STATE.get(machine_state) @dataclass(frozen=True, kw_only=True) @@ -96,8 +136,8 @@ class WhirlpoolSensorEntityDescription(SensorEntityDescription): value_fn: Callable[[Appliance], str | None] -WASHER_DRYER_STATE_OPTIONS = [ - *WASHER_DRYER_MACHINE_STATE.values(), +WASHER_STATE_OPTIONS = [ + *WASHER_MACHINE_STATE.values(), STATE_CYCLE_FILLING, STATE_CYCLE_RINSING, STATE_CYCLE_SENSING, @@ -107,13 +147,19 @@ class WhirlpoolSensorEntityDescription(SensorEntityDescription): STATE_DOOR_OPEN, ] +DRYER_STATE_OPTIONS = [ + *DRYER_MACHINE_STATE.values(), + STATE_CYCLE_SENSING, + STATE_DOOR_OPEN, +] + WASHER_SENSORS: tuple[WhirlpoolSensorEntityDescription, ...] = ( WhirlpoolSensorEntityDescription( key="state", translation_key="washer_state", device_class=SensorDeviceClass.ENUM, - options=WASHER_DRYER_STATE_OPTIONS, - value_fn=washer_dryer_state, + options=WASHER_STATE_OPTIONS, + value_fn=washer_state, ), WhirlpoolSensorEntityDescription( key="DispenseLevel", @@ -130,8 +176,8 @@ class WhirlpoolSensorEntityDescription(SensorEntityDescription): key="state", translation_key="dryer_state", device_class=SensorDeviceClass.ENUM, - options=WASHER_DRYER_STATE_OPTIONS, - value_fn=washer_dryer_state, + options=DRYER_STATE_OPTIONS, + value_fn=dryer_state, ), ) @@ -151,24 +197,40 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Config flow entry for Whirlpool sensors.""" - entities: list = [] appliances_manager = config_entry.runtime_data - for washer_dryer in appliances_manager.washer_dryers: - sensor_descriptions = ( - DRYER_SENSORS - if "dryer" in washer_dryer.appliance_info.data_model.lower() - else WASHER_SENSORS - ) - entities.extend( - WhirlpoolSensor(washer_dryer, description) - for description in sensor_descriptions - ) - entities.extend( - WasherDryerTimeSensor(washer_dryer, description) - for description in WASHER_DRYER_TIME_SENSORS - ) - async_add_entities(entities) + washer_sensors = [ + WhirlpoolSensor(washer, description) + for washer in appliances_manager.washers + for description in WASHER_SENSORS + ] + + washer_time_sensors = [ + WasherTimeSensor(washer, description) + for washer in appliances_manager.washers + for description in WASHER_DRYER_TIME_SENSORS + ] + + dryer_sensors = [ + WhirlpoolSensor(dryer, description) + for dryer in appliances_manager.dryers + for description in DRYER_SENSORS + ] + + dryer_time_sensors = [ + DryerTimeSensor(dryer, description) + for dryer in appliances_manager.dryers + for description in WASHER_DRYER_TIME_SENSORS + ] + + async_add_entities( + [ + *washer_sensors, + *washer_time_sensors, + *dryer_sensors, + *dryer_time_sensors, + ] + ) class WhirlpoolSensor(WhirlpoolEntity, SensorEntity): @@ -187,22 +249,30 @@ def native_value(self) -> StateType | str: return self.entity_description.value_fn(self._appliance) -class WasherDryerTimeSensor(WhirlpoolEntity, RestoreSensor): - """A timestamp class for the Whirlpool washer/dryer.""" +class WasherDryerTimeSensorBase(WhirlpoolEntity, RestoreSensor, ABC): + """Abstract base class for Whirlpool washer/dryer time sensors.""" _attr_should_poll = True + _appliance: Washer | Dryer def __init__( - self, washer_dryer: WasherDryer, description: SensorEntityDescription + self, appliance: Washer | Dryer, description: SensorEntityDescription ) -> None: - """Initialize the washer sensor.""" - super().__init__(washer_dryer, unique_id_suffix=f"-{description.key}") + """Initialize the washer/dryer sensor.""" + super().__init__(appliance, unique_id_suffix=f"-{description.key}") self.entity_description = description - self._wd = washer_dryer self._running: bool | None = None self._value: datetime | None = None + @abstractmethod + def _is_machine_state_finished(self) -> bool: + """Return true if the machine is in a finished state.""" + + @abstractmethod + def _is_machine_state_running(self) -> bool: + """Return true if the machine is in a running state.""" + async def async_added_to_hass(self) -> None: """Register attribute updates callback.""" if restored_data := await self.async_get_last_sensor_data(): @@ -212,28 +282,62 @@ async def async_added_to_hass(self) -> None: async def async_update(self) -> None: """Update status of Whirlpool.""" - await self._wd.fetch_data() + await self._appliance.fetch_data() @override @property def native_value(self) -> datetime | None: """Calculate the time stamp for completion.""" - machine_state = self._wd.get_machine_state() now = utcnow() - if ( - machine_state.value - in {MachineState.Complete.value, MachineState.Standby.value} - and self._running - ): + + if self._is_machine_state_finished() and self._running: self._running = False self._value = now - if machine_state is MachineState.RunningMainCycle: + if self._is_machine_state_running(): self._running = True - new_timestamp = now + timedelta(seconds=self._wd.get_time_remaining()) + new_timestamp = now + timedelta( + seconds=self._appliance.get_time_remaining() + ) if self._value is None or ( isinstance(self._value, datetime) and abs(new_timestamp - self._value) > timedelta(seconds=60) ): self._value = new_timestamp return self._value + + +class WasherTimeSensor(WasherDryerTimeSensorBase): + """A timestamp class for Whirlpool washers.""" + + _appliance: Washer + + def _is_machine_state_finished(self) -> bool: + """Return true if the machine is in a finished state.""" + return self._appliance.get_machine_state() in { + WasherMachineState.Complete, + WasherMachineState.Standby, + } + + def _is_machine_state_running(self) -> bool: + """Return true if the machine is in a running state.""" + return ( + self._appliance.get_machine_state() is WasherMachineState.RunningMainCycle + ) + + +class DryerTimeSensor(WasherDryerTimeSensorBase): + """A timestamp class for Whirlpool dryers.""" + + _appliance: Dryer + + def _is_machine_state_finished(self) -> bool: + """Return true if the machine is in a finished state.""" + return self._appliance.get_machine_state() in { + DryerMachineState.Complete, + DryerMachineState.Standby, + } + + def _is_machine_state_running(self) -> bool: + """Return true if the machine is in a running state.""" + return self._appliance.get_machine_state() is DryerMachineState.RunningMainCycle diff --git a/homeassistant/components/zwave_js/config_flow.py b/homeassistant/components/zwave_js/config_flow.py index 2c37ee4b554301..a109719965cbf1 100644 --- a/homeassistant/components/zwave_js/config_flow.py +++ b/homeassistant/components/zwave_js/config_flow.py @@ -845,11 +845,8 @@ async def async_step_intent_migrate( }, ) - if user_input is not None: - self._migrating = True - return await self.async_step_backup_nvm() - - return self.async_show_form(step_id="intent_migrate") + self._migrating = True + return await self.async_step_backup_nvm() async def async_step_backup_nvm( self, user_input: dict[str, Any] | None = None @@ -904,7 +901,7 @@ async def async_step_restore_nvm( async def async_step_instruct_unplug( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Reset the current controller, and instruct the user to unplug it.""" + """Instruct the user to unplug the old controller.""" if user_input is not None: if self.usb_path: @@ -914,63 +911,9 @@ async def async_step_instruct_unplug( # Now that the old controller is gone, we can scan for serial ports again return await self.async_step_choose_serial_port() - try: - driver = self._get_driver() - except AbortFlow: - return self.async_abort(reason="config_entry_not_loaded") - - @callback - def set_driver_ready(event: dict) -> None: - "Set the driver ready event." - wait_driver_ready.set() - - wait_driver_ready = asyncio.Event() - - unsubscribe = driver.once("driver ready", set_driver_ready) - - # reset the old controller - try: - await driver.async_hard_reset() - except FailedCommand as err: - unsubscribe() - _LOGGER.error("Failed to reset controller: %s", err) - return self.async_abort(reason="reset_failed") - - # Update the unique id of the config entry - # to the new home id, which requires waiting for the driver - # to be ready before getting the new home id. - # If the backup restore, done later in the flow, fails, - # the config entry unique id should be the new home id - # after the controller reset. - try: - async with asyncio.timeout(DRIVER_READY_TIMEOUT): - await wait_driver_ready.wait() - except TimeoutError: - pass - finally: - unsubscribe() - config_entry = self._reconfigure_config_entry assert config_entry is not None - try: - version_info = await async_get_version_info( - self.hass, config_entry.data[CONF_URL] - ) - except CannotConnect: - # Just log this error, as there's nothing to do about it here. - # The stale unique id needs to be handled by a repair flow, - # after the config entry has been reloaded, if the backup restore - # also fails. - _LOGGER.debug( - "Failed to get server version, cannot update config entry " - "unique id with new home id, after controller reset" - ) - else: - self.hass.config_entries.async_update_entry( - config_entry, unique_id=str(version_info.home_id) - ) - # Unload the config entry before asking the user to unplug the controller. await self.hass.config_entries.async_unload(config_entry.entry_id) diff --git a/homeassistant/components/zwave_js/strings.json b/homeassistant/components/zwave_js/strings.json index d9a3b82a47ca62..f61d871cfb90b5 100644 --- a/homeassistant/components/zwave_js/strings.json +++ b/homeassistant/components/zwave_js/strings.json @@ -108,13 +108,9 @@ "intent_reconfigure": "Re-configure the current controller" } }, - "intent_migrate": { - "title": "[%key:component::zwave_js::config::step::reconfigure::menu_options::intent_migrate%]", - "description": "Before setting up your new controller, your old controller needs to be reset. A backup will be performed first.\n\nDo you wish to continue?" - }, "instruct_unplug": { "title": "Unplug your old controller", - "description": "Backup saved to \"{file_path}\"\n\nYour old controller has been reset. If the hardware is no longer needed, you can now unplug it.\n\nPlease make sure your new controller is plugged in before continuing." + "description": "Backup saved to \"{file_path}\"\n\nYour old controller has not been reset. You should now unplug it to prevent it from interfering with the new controller.\n\nPlease make sure your new controller is plugged in before continuing." }, "restore_failed": { "title": "Restoring unsuccessful", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index bd88338c4b9f7a..a44be6059ec7d4 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -3165,8 +3165,7 @@ "name": "Jellyfin", "integration_type": "service", "config_flow": true, - "iot_class": "local_polling", - "single_config_entry": true + "iot_class": "local_polling" }, "jewish_calendar": { "name": "Jewish Calendar", diff --git a/requirements_all.txt b/requirements_all.txt index 9bc728320a7474..32bbdb0ce9aa02 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1279,7 +1279,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.10.0 +jellyfin-apiclient-python==1.11.0 # homeassistant.components.command_line # homeassistant.components.rest @@ -2166,7 +2166,7 @@ pymsteams==0.1.12 pymysensors==0.25.0 # homeassistant.components.iron_os -pynecil==4.1.0 +pynecil==4.1.1 # homeassistant.components.netgear pynetgear==0.10.10 @@ -3102,7 +3102,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.6.10 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.20.0 +whirlpool-sixth-sense==0.21.1 # homeassistant.components.whois whois==0.9.27 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 8a5f97014e2cec..5e132c7ee33cc2 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1107,7 +1107,7 @@ israel-rail-api==0.1.2 jaraco.abode==6.2.1 # homeassistant.components.jellyfin -jellyfin-apiclient-python==1.10.0 +jellyfin-apiclient-python==1.11.0 # homeassistant.components.command_line # homeassistant.components.rest @@ -1799,7 +1799,7 @@ pymonoprice==0.4 pymysensors==0.25.0 # homeassistant.components.iron_os -pynecil==4.1.0 +pynecil==4.1.1 # homeassistant.components.netgear pynetgear==0.10.10 @@ -2555,7 +2555,7 @@ webmin-xmlrpc==0.0.2 weheat==2025.6.10 # homeassistant.components.whirlpool -whirlpool-sixth-sense==0.20.0 +whirlpool-sixth-sense==0.21.1 # homeassistant.components.whois whois==0.9.27 diff --git a/tests/components/devolo_home_control/test_binary_sensor.py b/tests/components/devolo_home_control/test_binary_sensor.py index b2a58ef5038842..657e93a5b908c7 100644 --- a/tests/components/devolo_home_control/test_binary_sensor.py +++ b/tests/components/devolo_home_control/test_binary_sensor.py @@ -5,9 +5,10 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.binary_sensor import DOMAIN as BINARY_SENSOR_DOMAIN +from homeassistant.components.devolo_home_control.const import DOMAIN from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er +from homeassistant.helpers import device_registry as dr, entity_registry as er from . import configure_integration from .mocks import ( @@ -19,7 +20,10 @@ async def test_binary_sensor( - hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + snapshot: SnapshotAssertion, ) -> None: """Test setup and state change of a binary sensor device.""" entry = configure_integration(hass) @@ -55,6 +59,12 @@ async def test_binary_sensor( hass.states.get(f"{BINARY_SENSOR_DOMAIN}.test_door").state == STATE_UNAVAILABLE ) + # Emulate websocket message: device was deleted + test_gateway.publisher.dispatch("Test", ("Test", "del")) + await hass.async_block_till_done() + device = device_registry.async_get_device(identifiers={(DOMAIN, "Test")}) + assert not device + async def test_remote_control( hass: HomeAssistant, entity_registry: er.EntityRegistry, snapshot: SnapshotAssertion diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index 530308fdf41a72..d5039f3b0bde68 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -6,6 +6,7 @@ from typing import Any from unittest.mock import AsyncMock, MagicMock, Mock, call, patch +from aiohttp import ClientError from ha_silabs_firmware_client import ( FirmwareManifest, FirmwareMetadata, @@ -80,7 +81,7 @@ async def async_step_install_zigbee_firmware( firmware_name="Zigbee", expected_installed_firmware_type=ApplicationType.EZSP, step_id="install_zigbee_firmware", - next_step_id="confirm_zigbee", + next_step_id="pre_confirm_zigbee", ) async def async_step_install_thread_firmware( @@ -137,7 +138,7 @@ async def async_step_install_zigbee_firmware( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Install Zigbee firmware.""" - return await self.async_step_confirm_zigbee() + return await self.async_step_pre_confirm_zigbee() async def async_step_install_thread_firmware( self, user_input: dict[str, Any] | None = None @@ -208,6 +209,7 @@ def mock_firmware_info( *, is_hassio: bool = True, probe_app_type: ApplicationType | None = ApplicationType.EZSP, + probe_fw_version: str | None = "2.4.4.0", otbr_addon_info: AddonInfo = AddonInfo( available=True, hostname=None, @@ -217,6 +219,7 @@ def mock_firmware_info( version=None, ), flash_app_type: ApplicationType = ApplicationType.EZSP, + flash_fw_version: str | None = "7.4.4.0", ) -> Iterator[tuple[Mock, Mock]]: """Mock the main addon states for the config flow.""" mock_otbr_manager = Mock(spec_set=get_otbr_addon_manager(hass)) @@ -243,7 +246,14 @@ def mock_firmware_info( checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", size=123, release_notes="Some release notes", - metadata={}, + metadata={ + "baudrate": 460800, + "fw_type": "openthread_rcp", + "fw_variant": None, + "metadata_version": 2, + "ot_rcp_version": "SL-OPENTHREAD/2.4.4.0_GitHub-7074a43e4", + "sdk_version": "4.4.4", + }, url=TEST_RELEASES_URL / "fake_openthread_rcp_7.4.4.0_variant.gbl", ), FirmwareMetadata( @@ -251,7 +261,14 @@ def mock_firmware_info( checksum="sha256:1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef", size=123, release_notes="Some release notes", - metadata={}, + metadata={ + "baudrate": 115200, + "ezsp_version": "7.4.4.0", + "fw_type": "zigbee_ncp", + "fw_variant": None, + "metadata_version": 2, + "sdk_version": "4.4.4", + }, url=TEST_RELEASES_URL / "fake_zigbee_ncp_7.4.4.0_variant.gbl", ), ], @@ -263,7 +280,7 @@ def mock_firmware_info( probed_firmware_info = FirmwareInfo( device="/dev/ttyUSB0", # Not used firmware_type=probe_app_type, - firmware_version=None, + firmware_version=probe_fw_version, owners=[], source="probe", ) @@ -274,7 +291,7 @@ def mock_firmware_info( flashed_firmware_info = FirmwareInfo( device=TEST_DEVICE, firmware_type=flash_app_type, - firmware_version="7.4.4.0", + firmware_version=flash_fw_version, owners=[create_mock_owner()], source="probe", ) @@ -333,7 +350,7 @@ async def mock_flash_firmware( side_effect=mock_flash_firmware, ), ): - yield mock_otbr_manager + yield mock_otbr_manager, mock_update_client async def consume_progress_flow( @@ -411,6 +428,91 @@ async def test_config_flow_zigbee(hass: HomeAssistant) -> None: assert zha_flow["step_id"] == "confirm" +async def test_config_flow_firmware_index_download_fails_but_not_required( + hass: HomeAssistant, +) -> None: + """Test flow continues if index download fails but install is not required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with mock_firmware_info( + hass, + # The correct firmware is already installed + probe_app_type=ApplicationType.EZSP, + # An older version is probed, so an upgrade is attempted + probe_fw_version="7.4.3.0", + ) as (_, mock_update_client): + # Mock the firmware download to fail + mock_update_client.async_update_data.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.FORM + assert pick_result["step_id"] == "confirm_zigbee" + + +async def test_config_flow_firmware_download_fails_but_not_required( + hass: HomeAssistant, +) -> None: + """Test flow continues if firmware download fails but install is not required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + # The correct firmware is already installed so installation isn't required + probe_app_type=ApplicationType.EZSP, + # An older version is probed, so an upgrade is attempted + probe_fw_version="7.4.3.0", + ) as (_, mock_update_client), + ): + mock_update_client.async_fetch_firmware.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.FORM + assert pick_result["step_id"] == "confirm_zigbee" + + +async def test_config_flow_doesnt_downgrade( + hass: HomeAssistant, +) -> None: + """Test flow exits early, without downgrading firmware.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + probe_app_type=ApplicationType.EZSP, + # An newer version is probed than what we offer + probe_fw_version="7.5.0.0", + ), + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.async_flash_silabs_firmware" + ) as mock_async_flash_silabs_firmware, + ): + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.FORM + assert pick_result["step_id"] == "confirm_zigbee" + + assert len(mock_async_flash_silabs_firmware.mock_calls) == 0 + + async def test_config_flow_zigbee_skip_step_if_installed(hass: HomeAssistant) -> None: """Test the config flow, skip installing the addon if necessary.""" result = await hass.config_entries.flow.async_init( @@ -480,7 +582,7 @@ async def test_config_flow_thread(hass: HomeAssistant) -> None: hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): # Pick the menu option pick_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], @@ -564,7 +666,7 @@ async def test_config_flow_thread_addon_already_installed(hass: HomeAssistant) - update_available=False, version=None, ), - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): # Pick the menu option pick_result = await hass.config_entries.flow.async_configure( init_result["flow_id"], @@ -631,7 +733,7 @@ async def test_options_flow_zigbee_to_thread(hass: HomeAssistant) -> None: hass, probe_app_type=ApplicationType.EZSP, flash_app_type=ApplicationType.SPINEL, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): # First step is confirmation result = await hass.config_entries.options.async_init(config_entry.entry_id) assert result["type"] is FlowResultType.MENU diff --git a/tests/components/homeassistant_hardware/test_config_flow_failures.py b/tests/components/homeassistant_hardware/test_config_flow_failures.py index 65a5f58b17da78..442cf8aea50b03 100644 --- a/tests/components/homeassistant_hardware/test_config_flow_failures.py +++ b/tests/components/homeassistant_hardware/test_config_flow_failures.py @@ -2,6 +2,7 @@ from unittest.mock import AsyncMock, patch +from aiohttp import ClientError import pytest from homeassistant.components.hassio import AddonError, AddonInfo, AddonState @@ -109,7 +110,7 @@ async def test_config_flow_thread_addon_info_fails(hass: HomeAssistant) -> None: with mock_firmware_info( hass, probe_app_type=ApplicationType.EZSP, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_get_addon_info.side_effect = AddonError() result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={} @@ -147,7 +148,7 @@ async def test_config_flow_thread_addon_already_configured(hass: HomeAssistant) update_available=False, version="1.0.0", ), - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_install_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -178,7 +179,7 @@ async def test_config_flow_thread_addon_install_fails(hass: HomeAssistant) -> No with mock_firmware_info( hass, probe_app_type=ApplicationType.EZSP, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_install_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -209,7 +210,7 @@ async def test_config_flow_thread_addon_set_config_fails(hass: HomeAssistant) -> with mock_firmware_info( hass, probe_app_type=ApplicationType.EZSP, - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): async def install_addon() -> None: mock_otbr_manager.async_get_addon_info.return_value = AddonInfo( @@ -270,7 +271,7 @@ async def test_config_flow_thread_flasher_run_fails(hass: HomeAssistant) -> None update_available=False, version="1.0.0", ), - ) as mock_otbr_manager: + ) as (mock_otbr_manager, _): mock_otbr_manager.async_start_addon_waiting = AsyncMock( side_effect=AddonError() ) @@ -341,6 +342,64 @@ async def test_config_flow_thread_confirmation_fails(hass: HomeAssistant) -> Non assert pick_thread_progress_result["reason"] == "unsupported_firmware" +@pytest.mark.parametrize( + "ignore_translations_for_mock_domains", ["test_firmware_domain"] +) +async def test_config_flow_firmware_index_download_fails_and_required( + hass: HomeAssistant, +) -> None: + """Test flow aborts if OTA index download fails and install is required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + # The wrong firmware is installed, so a new install is required + probe_app_type=ApplicationType.SPINEL, + ) as (_, mock_update_client), + ): + mock_update_client.async_update_data.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.ABORT + assert pick_result["reason"] == "fw_download_failed" + + +@pytest.mark.parametrize( + "ignore_translations_for_mock_domains", ["test_firmware_domain"] +) +async def test_config_flow_firmware_download_fails_and_required( + hass: HomeAssistant, +) -> None: + """Test flow aborts if firmware download fails and install is required.""" + init_result = await hass.config_entries.flow.async_init( + TEST_DOMAIN, context={"source": "hardware"} + ) + + with ( + mock_firmware_info( + hass, + # The wrong firmware is installed, so a new install is required + probe_app_type=ApplicationType.SPINEL, + ) as (_, mock_update_client), + ): + mock_update_client.async_fetch_firmware.side_effect = ClientError() + + pick_result = await hass.config_entries.flow.async_configure( + init_result["flow_id"], + user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, + ) + + assert pick_result["type"] is FlowResultType.ABORT + assert pick_result["reason"] == "fw_download_failed" + + @pytest.mark.parametrize( "ignore_translations_for_mock_domains", ["test_firmware_domain"], diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 9dcac0732c9c3f..4df3efab360f64 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -75,7 +75,7 @@ async def mock_install_firmware_step( next_step_id: str, ) -> ConfigFlowResult: if next_step_id == "start_otbr_addon": - next_step_id = "confirm_otbr" + next_step_id = "pre_confirm_otbr" return await getattr(self, f"async_step_{next_step_id}")(user_input={}) @@ -100,14 +100,22 @@ async def mock_install_firmware_step( ), ), ): - result = await hass.config_entries.flow.async_configure( + confirm_result = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": step}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == ( + "confirm_zigbee" if step == STEP_PICK_FIRMWARE_ZIGBEE else "confirm_otbr" + ) + + create_result = await hass.config_entries.flow.async_configure( + confirm_result["flow_id"], user_input={} + ) - config_entry = result["result"] + assert create_result["type"] is FlowResultType.CREATE_ENTRY + config_entry = create_result["result"] assert config_entry.data == { "firmware": fw_type.value, "firmware_version": fw_version, @@ -171,7 +179,7 @@ async def test_options_flow( assert result["description_placeholders"]["model"] == model async def mock_async_step_pick_firmware_zigbee(self, data): - return await self.async_step_confirm_zigbee(user_input={}) + return await self.async_step_pre_confirm_zigbee() with ( patch( @@ -190,13 +198,20 @@ async def mock_async_step_pick_firmware_zigbee(self, data): ), ), ): - result = await hass.config_entries.options.async_configure( + confirm_result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": STEP_PICK_FIRMWARE_ZIGBEE}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"] is True + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == "confirm_zigbee" + + create_result = await hass.config_entries.options.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + assert create_result["result"] is True assert config_entry.data == { "firmware": "ezsp", diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index cd4a194105090e..7f622e0ed8fda1 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -348,7 +348,7 @@ async def test_firmware_options_flow( assert result["description_placeholders"]["model"] == "Home Assistant Yellow" async def mock_async_step_pick_firmware_zigbee(self, data): - return await self.async_step_confirm_zigbee(user_input={}) + return await self.async_step_pre_confirm_zigbee() async def mock_install_firmware_step( self, @@ -360,11 +360,16 @@ async def mock_install_firmware_step( next_step_id: str, ) -> ConfigFlowResult: if next_step_id == "start_otbr_addon": - next_step_id = "confirm_otbr" + next_step_id = "pre_confirm_otbr" return await getattr(self, f"async_step_{next_step_id}")(user_input={}) with ( + patch( + "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareOptionsFlow.async_step_pick_firmware_zigbee", + autospec=True, + side_effect=mock_async_step_pick_firmware_zigbee, + ), patch( "homeassistant.components.homeassistant_hardware.firmware_config_flow.BaseFirmwareConfigFlow._ensure_thread_addon_setup", return_value=None, @@ -385,13 +390,22 @@ async def mock_install_firmware_step( ), ), ): - result = await hass.config_entries.options.async_configure( + confirm_result = await hass.config_entries.options.async_configure( result["flow_id"], user_input={"next_step_id": step}, ) - assert result["type"] is FlowResultType.CREATE_ENTRY - assert result["result"] is True + assert confirm_result["type"] is FlowResultType.FORM + assert confirm_result["step_id"] == ( + "confirm_zigbee" if step == STEP_PICK_FIRMWARE_ZIGBEE else "confirm_otbr" + ) + + create_result = await hass.config_entries.options.async_configure( + confirm_result["flow_id"], user_input={} + ) + + assert create_result["type"] is FlowResultType.CREATE_ENTRY + assert create_result["result"] is True assert config_entry.data == { "firmware": fw_type.value, diff --git a/tests/components/jellyfin/fixtures/get-user-settings.json b/tests/components/jellyfin/fixtures/get-user-settings.json index 5e28f87d8f208e..5ed59661a60603 100644 --- a/tests/components/jellyfin/fixtures/get-user-settings.json +++ b/tests/components/jellyfin/fixtures/get-user-settings.json @@ -1,5 +1,5 @@ { - "Id": "string", + "Id": "USER-UUID", "ViewType": "string", "SortBy": "string", "IndexBy": "string", diff --git a/tests/components/jellyfin/test_config_flow.py b/tests/components/jellyfin/test_config_flow.py index a8ffbcbf46cd90..fd9d3b1d773be1 100644 --- a/tests/components/jellyfin/test_config_flow.py +++ b/tests/components/jellyfin/test_config_flow.py @@ -23,17 +23,6 @@ pytestmark = pytest.mark.usefixtures("mock_setup_entry") -async def test_abort_if_existing_entry(hass: HomeAssistant) -> None: - """Check flow abort when an entry already exist.""" - MockConfigEntry(domain=DOMAIN).add_to_hass(hass) - - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": config_entries.SOURCE_USER} - ) - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" - - async def test_form( hass: HomeAssistant, mock_jellyfin: MagicMock, @@ -201,6 +190,32 @@ async def test_form_persists_device_id_on_error( } +async def test_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_jellyfin: MagicMock, + mock_client: MagicMock, +) -> None: + """Test the case where the user tries to configure an already configured entry.""" + + 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() + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=USER_INPUT, + ) + await hass.async_block_till_done() + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + async def test_reauth( hass: HomeAssistant, mock_config_entry: MockConfigEntry, diff --git a/tests/components/lamarzocco/test_sensor.py b/tests/components/lamarzocco/test_sensor.py index 183d3f2daa6461..dee2fa0b79c568 100644 --- a/tests/components/lamarzocco/test_sensor.py +++ b/tests/components/lamarzocco/test_sensor.py @@ -2,11 +2,11 @@ from unittest.mock import MagicMock, patch -from pylamarzocco.const import ModelName +from pylamarzocco.const import MachineState, ModelName, WidgetType import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.const import STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -52,3 +52,27 @@ async def test_steam_ready_entity_for_all_machines( entry = entity_registry.async_get(state.entity_id) assert entry + + +async def test_sensors_unavailable_if_machine_off( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco switches are unavailable when the device is offline.""" + SWITCHES_UNAVAILABLE = ( + ("sensor.gs012345_steam_boiler_ready_time", True), + ("sensor.gs012345_coffee_boiler_ready_time", True), + ("sensor.gs012345_total_coffees_made", False), + ) + mock_lamarzocco.dashboard.config[ + WidgetType.CM_MACHINE_STATUS + ].status = MachineState.OFF + with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SENSOR]): + await async_init_integration(hass, mock_config_entry) + + for sensor, available in SWITCHES_UNAVAILABLE: + state = hass.states.get(sensor) + assert state + assert (state.state == STATE_UNAVAILABLE) == available diff --git a/tests/components/lamarzocco/test_switch.py b/tests/components/lamarzocco/test_switch.py index 0f1c4fd6ebb06d..c715c23b78f490 100644 --- a/tests/components/lamarzocco/test_switch.py +++ b/tests/components/lamarzocco/test_switch.py @@ -3,7 +3,7 @@ from typing import Any from unittest.mock import MagicMock, patch -from pylamarzocco.const import SmartStandByType +from pylamarzocco.const import MachineState, SmartStandByType, WidgetType from pylamarzocco.exceptions import RequestNotSuccessful import pytest from syrupy.assertion import SnapshotAssertion @@ -13,7 +13,7 @@ SERVICE_TURN_OFF, SERVICE_TURN_ON, ) -from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.const import ATTR_ENTITY_ID, STATE_UNAVAILABLE, Platform from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import entity_registry as er @@ -197,3 +197,25 @@ async def test_switch_exceptions( blocking=True, ) assert exc_info.value.translation_key == "auto_on_off_error" + + +async def test_switches_unavailable_if_machine_off( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + mock_lamarzocco: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the La Marzocco switches are unavailable when the device is offline.""" + mock_lamarzocco.dashboard.config[ + WidgetType.CM_MACHINE_STATUS + ].status = MachineState.OFF + with patch("homeassistant.components.lamarzocco.PLATFORMS", [Platform.SWITCH]): + await async_init_integration(hass, mock_config_entry) + + switches = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for switch in switches: + state = hass.states.get(switch.entity_id) + assert state + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/reolink/test_light.py b/tests/components/reolink/test_light.py index 07f2c58eb4332d..c3655ec00dfdd9 100644 --- a/tests/components/reolink/test_light.py +++ b/tests/components/reolink/test_light.py @@ -22,35 +22,23 @@ from tests.common import MockConfigEntry +@pytest.mark.parametrize( + ("whiteled_brightness", "expected_brightness"), + [ + (100, 255), + (None, None), + ], +) async def test_light_state( hass: HomeAssistant, config_entry: MockConfigEntry, reolink_host: MagicMock, + whiteled_brightness: int | None, + expected_brightness: int | None, ) -> None: """Test light entity state with floodlight.""" reolink_host.whiteled_state.return_value = True - reolink_host.whiteled_brightness.return_value = 100 - - with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): - assert await hass.config_entries.async_setup(config_entry.entry_id) - await hass.async_block_till_done() - assert config_entry.state is ConfigEntryState.LOADED - - entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" - - state = hass.states.get(entity_id) - assert state.state == STATE_ON - assert state.attributes["brightness"] == 255 - - -async def test_light_brightness_none( - hass: HomeAssistant, - config_entry: MockConfigEntry, - reolink_host: MagicMock, -) -> None: - """Test light entity with floodlight and brightness returning None.""" - reolink_host.whiteled_state.return_value = True - reolink_host.whiteled_brightness.return_value = None + reolink_host.whiteled_brightness.return_value = whiteled_brightness with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): assert await hass.config_entries.async_setup(config_entry.entry_id) @@ -61,7 +49,7 @@ async def test_light_brightness_none( state = hass.states.get(entity_id) assert state.state == STATE_ON - assert state.attributes["brightness"] is None + assert state.attributes["brightness"] == expected_brightness async def test_light_turn_off( @@ -118,30 +106,36 @@ async def test_light_turn_on( [call(0, brightness=20), call(0, state=True)] ) - reolink_host.set_whiteled.side_effect = ReolinkError("Test error") - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id}, - blocking=True, - ) - reolink_host.set_whiteled.side_effect = ReolinkError("Test error") - with pytest.raises(HomeAssistantError): - await hass.services.async_call( - LIGHT_DOMAIN, - SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 51}, - blocking=True, - ) +@pytest.mark.parametrize( + ("exception", "service_data"), + [ + (ReolinkError("Test error"), {}), + (ReolinkError("Test error"), {ATTR_BRIGHTNESS: 51}), + (InvalidParameterError("Test error"), {ATTR_BRIGHTNESS: 51}), + ], +) +async def test_light_turn_on_errors( + hass: HomeAssistant, + config_entry: MockConfigEntry, + reolink_host: MagicMock, + exception: Exception, + service_data: dict, +) -> None: + """Test light turn on service error cases.""" + with patch("homeassistant.components.reolink.PLATFORMS", [Platform.LIGHT]): + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + assert config_entry.state is ConfigEntryState.LOADED + + entity_id = f"{Platform.LIGHT}.{TEST_NVR_NAME}_floodlight" - reolink_host.set_whiteled.side_effect = InvalidParameterError("Test error") + reolink_host.set_whiteled.side_effect = exception with pytest.raises(HomeAssistantError): await hass.services.async_call( LIGHT_DOMAIN, SERVICE_TURN_ON, - {ATTR_ENTITY_ID: entity_id, ATTR_BRIGHTNESS: 51}, + {ATTR_ENTITY_ID: entity_id, **service_data}, blocking=True, ) diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index e13fab8f28b356..2af90b9f7efa39 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -121,13 +121,13 @@ async def test_reconfigure_flow_broadcast( async def test_reconfigure_flow_webhooks( hass: HomeAssistant, - mock_webhooks_config_entry: MockConfigEntry, + mock_broadcast_config_entry: MockConfigEntry, mock_external_calls: None, ) -> None: """Test reconfigure flow for webhook.""" - mock_webhooks_config_entry.add_to_hass(hass) + mock_broadcast_config_entry.add_to_hass(hass) - result = await mock_webhooks_config_entry.start_reconfigure_flow(hass) + result = await mock_broadcast_config_entry.start_reconfigure_flow(hass) assert result["step_id"] == "reconfigure" assert result["type"] is FlowResultType.FORM assert result["errors"] is None @@ -198,8 +198,8 @@ async def test_reconfigure_flow_webhooks( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reconfigure_successful" - assert mock_webhooks_config_entry.data[CONF_URL] == "https://reconfigure" - assert mock_webhooks_config_entry.data[CONF_TRUSTED_NETWORKS] == [ + assert mock_broadcast_config_entry.data[CONF_URL] == "https://reconfigure" + assert mock_broadcast_config_entry.data[CONF_TRUSTED_NETWORKS] == [ "149.154.160.0/20" ] @@ -499,9 +499,22 @@ async def test_import_multiple( CONF_BOT_COUNT: 2, } - with patch( - "homeassistant.components.telegram_bot.config_flow.Bot.get_me", - return_value=User(123456, "Testbot", True), + with ( + patch( + "homeassistant.components.telegram_bot.config_flow.Bot.get_me", + return_value=User(123456, "Testbot", True), + ), + patch( + "homeassistant.components.telegram_bot.config_flow.Bot.get_chat", + return_value=ChatFullInfo( + id=987654321, + title="mock title", + first_name="mock first_name", + type="PRIVATE", + max_reaction_count=100, + accent_color_id=AccentColor.COLOR_000, + ), + ), ): # test: import first entry success diff --git a/tests/components/whirlpool/conftest.py b/tests/components/whirlpool/conftest.py index 7447c1edd5ac74..fb82750924aa40 100644 --- a/tests/components/whirlpool/conftest.py +++ b/tests/components/whirlpool/conftest.py @@ -4,7 +4,7 @@ from unittest.mock import Mock import pytest -from whirlpool import aircon, appliancesmanager, auth, washerdryer +from whirlpool import aircon, appliancesmanager, auth, dryer, washer from whirlpool.backendselector import Brand, Region from .const import MOCK_SAID1, MOCK_SAID2 @@ -66,10 +66,8 @@ def fixture_mock_appliances_manager_api( mock_aircon1_api, mock_aircon2_api, ] - mock_appliances_manager.return_value.washer_dryers = [ - mock_washer_api, - mock_dryer_api, - ] + mock_appliances_manager.return_value.washers = [mock_washer_api] + mock_appliances_manager.return_value.dryers = [mock_dryer_api] yield mock_appliances_manager @@ -123,15 +121,13 @@ def fixture_mock_aircon2_api(): @pytest.fixture def mock_washer_api(): """Get a mock of a washer.""" - mock_washer = Mock(spec=washerdryer.WasherDryer, said="said_washer") + mock_washer = Mock(spec=washer.Washer, said="said_washer") mock_washer.name = "Washer" mock_washer.appliance_info = Mock( data_model="washer", category="washer_dryer", model_number="12345" ) mock_washer.get_online.return_value = True - mock_washer.get_machine_state.return_value = ( - washerdryer.MachineState.RunningMainCycle - ) + mock_washer.get_machine_state.return_value = washer.MachineState.RunningMainCycle mock_washer.get_door_open.return_value = False mock_washer.get_dispense_1_level.return_value = 3 mock_washer.get_time_remaining.return_value = 3540 @@ -148,21 +144,14 @@ def mock_washer_api(): @pytest.fixture def mock_dryer_api(): """Get a mock of a dryer.""" - mock_dryer = mock.Mock(spec=washerdryer.WasherDryer, said="said_dryer") + mock_dryer = mock.Mock(spec=dryer.Dryer, said="said_dryer") mock_dryer.name = "Dryer" mock_dryer.appliance_info = Mock( data_model="dryer", category="washer_dryer", model_number="12345" ) mock_dryer.get_online.return_value = True - mock_dryer.get_machine_state.return_value = ( - washerdryer.MachineState.RunningMainCycle - ) + mock_dryer.get_machine_state.return_value = dryer.MachineState.RunningMainCycle mock_dryer.get_door_open.return_value = False mock_dryer.get_time_remaining.return_value = 3540 - mock_dryer.get_cycle_status_filling.return_value = False - mock_dryer.get_cycle_status_rinsing.return_value = False mock_dryer.get_cycle_status_sensing.return_value = False - mock_dryer.get_cycle_status_soaking.return_value = False - mock_dryer.get_cycle_status_spinning.return_value = False - mock_dryer.get_cycle_status_washing.return_value = False return mock_dryer diff --git a/tests/components/whirlpool/snapshots/test_diagnostics.ambr b/tests/components/whirlpool/snapshots/test_diagnostics.ambr index f1eef6f7dfcb4c..b48ed46d186cd1 100644 --- a/tests/components/whirlpool/snapshots/test_diagnostics.ambr +++ b/tests/components/whirlpool/snapshots/test_diagnostics.ambr @@ -14,14 +14,16 @@ 'model_number': '12345', }), }), - 'ovens': dict({ - }), - 'washer_dryers': dict({ + 'dryers': dict({ 'Dryer': dict({ 'category': 'washer_dryer', 'data_model': 'dryer', 'model_number': '12345', }), + }), + 'ovens': dict({ + }), + 'washers': dict({ 'Washer': dict({ 'category': 'washer_dryer', 'data_model': 'washer', diff --git a/tests/components/whirlpool/snapshots/test_sensor.ambr b/tests/components/whirlpool/snapshots/test_sensor.ambr index 843e71b62ea9cc..fa67b5ecc05ec6 100644 --- a/tests/components/whirlpool/snapshots/test_sensor.ambr +++ b/tests/components/whirlpool/snapshots/test_sensor.ambr @@ -75,12 +75,8 @@ 'demo_mode', 'hard_stop_or_error', 'system_initialize', - 'cycle_filling', - 'cycle_rinsing', + 'cancelled', 'cycle_sensing', - 'cycle_soaking', - 'cycle_spinning', - 'cycle_washing', 'door_open', ]), }), @@ -138,12 +134,8 @@ 'demo_mode', 'hard_stop_or_error', 'system_initialize', - 'cycle_filling', - 'cycle_rinsing', + 'cancelled', 'cycle_sensing', - 'cycle_soaking', - 'cycle_spinning', - 'cycle_washing', 'door_open', ]), }), diff --git a/tests/components/whirlpool/test_config_flow.py b/tests/components/whirlpool/test_config_flow.py index 6563f88515f2bf..92546acd773870 100644 --- a/tests/components/whirlpool/test_config_flow.py +++ b/tests/components/whirlpool/test_config_flow.py @@ -208,7 +208,8 @@ async def test_no_appliances_flow( original_aircons = mock_appliances_manager_api.return_value.aircons mock_appliances_manager_api.return_value.aircons = [] - mock_appliances_manager_api.return_value.washer_dryers = [] + mock_appliances_manager_api.return_value.washers = [] + mock_appliances_manager_api.return_value.dryers = [] result = await hass.config_entries.flow.async_configure( result["flow_id"], CONFIG_INPUT | {CONF_REGION: region[0], CONF_BRAND: brand[0]} ) diff --git a/tests/components/whirlpool/test_init.py b/tests/components/whirlpool/test_init.py index d33bd8be0e17a3..848a77c6b9e4bd 100644 --- a/tests/components/whirlpool/test_init.py +++ b/tests/components/whirlpool/test_init.py @@ -80,7 +80,8 @@ async def test_setup_no_appliances( ) -> None: """Test setup when there are no appliances available.""" mock_appliances_manager_api.return_value.aircons = [] - mock_appliances_manager_api.return_value.washer_dryers = [] + mock_appliances_manager_api.return_value.washers = [] + mock_appliances_manager_api.return_value.dryers = [] await init_integration(hass) assert len(hass.states.async_all()) == 0 diff --git a/tests/components/whirlpool/test_sensor.py b/tests/components/whirlpool/test_sensor.py index 6e28539d66155c..eaed27c95f8a14 100644 --- a/tests/components/whirlpool/test_sensor.py +++ b/tests/components/whirlpool/test_sensor.py @@ -5,7 +5,8 @@ from freezegun.api import FrozenDateTimeFactory import pytest from syrupy.assertion import SnapshotAssertion -from whirlpool.washerdryer import MachineState +from whirlpool.dryer import MachineState as DryerMachineState +from whirlpool.washer import MachineState as WasherMachineState from homeassistant.components.whirlpool.sensor import SCAN_INTERVAL from homeassistant.const import STATE_UNKNOWN, Platform @@ -63,7 +64,7 @@ async def test_washer_dryer_time_sensor( ) mock_instance = request.getfixturevalue(mock_fixture) - mock_instance.get_machine_state.return_value = MachineState.Pause + mock_instance.get_machine_state.return_value = WasherMachineState.Pause await init_integration(hass) # Test restored state. @@ -77,7 +78,15 @@ async def test_washer_dryer_time_sensor( assert state.state == restored_datetime.isoformat() # Test new time when machine starts a cycle. - mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle + if "washer" in entity_id: + mock_instance.get_machine_state.return_value = ( + WasherMachineState.RunningMainCycle + ) + else: + mock_instance.get_machine_state.return_value = ( + DryerMachineState.RunningMainCycle + ) + mock_instance.get_time_remaining.return_value = 60 await trigger_attr_callback(hass, mock_instance) @@ -127,7 +136,10 @@ async def test_washer_dryer_time_sensor_no_restore( now = utcnow() mock_instance = request.getfixturevalue(mock_fixture) - mock_instance.get_machine_state.return_value = MachineState.Pause + if "washer" in entity_id: + mock_instance.get_machine_state.return_value = WasherMachineState.Pause + else: + mock_instance.get_machine_state.return_value = DryerMachineState.Pause await init_integration(hass) state = hass.states.get(entity_id) @@ -140,7 +152,14 @@ async def test_washer_dryer_time_sensor_no_restore( assert state.state == STATE_UNKNOWN # Test new time when machine starts a cycle. - mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle + if "washer" in entity_id: + mock_instance.get_machine_state.return_value = ( + WasherMachineState.RunningMainCycle + ) + else: + mock_instance.get_machine_state.return_value = ( + DryerMachineState.RunningMainCycle + ) mock_instance.get_time_remaining.return_value = 60 await trigger_attr_callback(hass, mock_instance) @@ -149,63 +168,87 @@ async def test_washer_dryer_time_sensor_no_restore( assert state.state == expected_time -@pytest.mark.parametrize( - ("entity_id", "mock_fixture"), - [ - ("sensor.washer_state", "mock_washer_api"), - ("sensor.dryer_state", "mock_dryer_api"), - ], -) @pytest.mark.parametrize( ("machine_state", "expected_state"), [ - (MachineState.Standby, "standby"), - (MachineState.Setting, "setting"), - (MachineState.DelayCountdownMode, "delay_countdown"), - (MachineState.DelayPause, "delay_paused"), - (MachineState.SmartDelay, "smart_delay"), - (MachineState.SmartGridPause, "smart_grid_pause"), - (MachineState.Pause, "pause"), - (MachineState.RunningMainCycle, "running_maincycle"), - (MachineState.RunningPostCycle, "running_postcycle"), - (MachineState.Exceptions, "exception"), - (MachineState.Complete, "complete"), - (MachineState.PowerFailure, "power_failure"), - (MachineState.ServiceDiagnostic, "service_diagnostic_mode"), - (MachineState.FactoryDiagnostic, "factory_diagnostic_mode"), - (MachineState.LifeTest, "life_test"), - (MachineState.CustomerFocusMode, "customer_focus_mode"), - (MachineState.DemoMode, "demo_mode"), - (MachineState.HardStopOrError, "hard_stop_or_error"), - (MachineState.SystemInit, "system_initialize"), + (WasherMachineState.Standby, "standby"), + (WasherMachineState.Setting, "setting"), + (WasherMachineState.DelayCountdownMode, "delay_countdown"), + (WasherMachineState.DelayPause, "delay_paused"), + (WasherMachineState.SmartDelay, "smart_delay"), + (WasherMachineState.SmartGridPause, "smart_grid_pause"), + (WasherMachineState.Pause, "pause"), + (WasherMachineState.RunningMainCycle, "running_maincycle"), + (WasherMachineState.RunningPostCycle, "running_postcycle"), + (WasherMachineState.Exceptions, "exception"), + (WasherMachineState.Complete, "complete"), + (WasherMachineState.PowerFailure, "power_failure"), + (WasherMachineState.ServiceDiagnostic, "service_diagnostic_mode"), + (WasherMachineState.FactoryDiagnostic, "factory_diagnostic_mode"), + (WasherMachineState.LifeTest, "life_test"), + (WasherMachineState.CustomerFocusMode, "customer_focus_mode"), + (WasherMachineState.DemoMode, "demo_mode"), + (WasherMachineState.HardStopOrError, "hard_stop_or_error"), + (WasherMachineState.SystemInit, "system_initialize"), ], ) -async def test_washer_dryer_machine_states( +async def test_washer_machine_states( hass: HomeAssistant, - entity_id: str, - mock_fixture: str, - machine_state: MachineState, + machine_state: WasherMachineState, expected_state: str, - request: pytest.FixtureRequest, + mock_washer_api, ) -> None: - """Test Washer/Dryer machine states.""" - mock_instance = request.getfixturevalue(mock_fixture) + """Test Washer machine states.""" await init_integration(hass) - mock_instance.get_machine_state.return_value = machine_state - await trigger_attr_callback(hass, mock_instance) - state = hass.states.get(entity_id) + mock_washer_api.get_machine_state.return_value = machine_state + await trigger_attr_callback(hass, mock_washer_api) + state = hass.states.get("sensor.washer_state") assert state is not None assert state.state == expected_state @pytest.mark.parametrize( - ("entity_id", "mock_fixture"), + ("machine_state", "expected_state"), [ - ("sensor.washer_state", "mock_washer_api"), - ("sensor.dryer_state", "mock_dryer_api"), + (DryerMachineState.Standby, "standby"), + (DryerMachineState.Setting, "setting"), + (DryerMachineState.DelayCountdownMode, "delay_countdown"), + (DryerMachineState.DelayPause, "delay_paused"), + (DryerMachineState.SmartDelay, "smart_delay"), + (DryerMachineState.SmartGridPause, "smart_grid_pause"), + (DryerMachineState.Pause, "pause"), + (DryerMachineState.RunningMainCycle, "running_maincycle"), + (DryerMachineState.RunningPostCycle, "running_postcycle"), + (DryerMachineState.Exceptions, "exception"), + (DryerMachineState.Complete, "complete"), + (DryerMachineState.PowerFailure, "power_failure"), + (DryerMachineState.ServiceDiagnostic, "service_diagnostic_mode"), + (DryerMachineState.FactoryDiagnostic, "factory_diagnostic_mode"), + (DryerMachineState.LifeTest, "life_test"), + (DryerMachineState.CustomerFocusMode, "customer_focus_mode"), + (DryerMachineState.DemoMode, "demo_mode"), + (DryerMachineState.HardStopOrError, "hard_stop_or_error"), + (DryerMachineState.SystemInit, "system_initialize"), + (DryerMachineState.Cancelled, "cancelled"), ], ) +async def test_dryer_machine_states( + hass: HomeAssistant, + machine_state: DryerMachineState, + expected_state: str, + mock_dryer_api, +) -> None: + """Test Dryer machine states.""" + await init_integration(hass) + + mock_dryer_api.get_machine_state.return_value = machine_state + await trigger_attr_callback(hass, mock_dryer_api) + state = hass.states.get("sensor.dryer_state") + assert state is not None + assert state.state == expected_state + + @pytest.mark.parametrize( ( "filling", @@ -225,10 +268,8 @@ async def test_washer_dryer_machine_states( (False, False, False, False, False, True, "cycle_washing"), ], ) -async def test_washer_dryer_running_states( +async def test_washer_running_states( hass: HomeAssistant, - entity_id: str, - mock_fixture: str, filling: bool, rinsing: bool, sensing: bool, @@ -236,22 +277,21 @@ async def test_washer_dryer_running_states( spinning: bool, washing: bool, expected_state: str, - request: pytest.FixtureRequest, + mock_washer_api, ) -> None: - """Test Washer/Dryer machine states for RunningMainCycle.""" - mock_instance = request.getfixturevalue(mock_fixture) + """Test Washer machine states for RunningMainCycle.""" await init_integration(hass) - mock_instance.get_machine_state.return_value = MachineState.RunningMainCycle - mock_instance.get_cycle_status_filling.return_value = filling - mock_instance.get_cycle_status_rinsing.return_value = rinsing - mock_instance.get_cycle_status_sensing.return_value = sensing - mock_instance.get_cycle_status_soaking.return_value = soaking - mock_instance.get_cycle_status_spinning.return_value = spinning - mock_instance.get_cycle_status_washing.return_value = washing + mock_washer_api.get_machine_state.return_value = WasherMachineState.RunningMainCycle + mock_washer_api.get_cycle_status_filling.return_value = filling + mock_washer_api.get_cycle_status_rinsing.return_value = rinsing + mock_washer_api.get_cycle_status_sensing.return_value = sensing + mock_washer_api.get_cycle_status_soaking.return_value = soaking + mock_washer_api.get_cycle_status_spinning.return_value = spinning + mock_washer_api.get_cycle_status_washing.return_value = washing - await trigger_attr_callback(hass, mock_instance) - state = hass.states.get(entity_id) + await trigger_attr_callback(hass, mock_washer_api) + state = hass.states.get("sensor.washer_state") assert state is not None assert state.state == expected_state diff --git a/tests/components/zwave_js/test_config_flow.py b/tests/components/zwave_js/test_config_flow.py index 2e41a176a9c229..e99cedbdcbae86 100644 --- a/tests/components/zwave_js/test_config_flow.py +++ b/tests/components/zwave_js/test_config_flow.py @@ -867,8 +867,6 @@ async def test_usb_discovery_migration( get_server_version: AsyncMock, ) -> None: """Test usb discovery migration.""" - version_info = get_server_version.return_value - version_info.home_id = 4321 addon_options["device"] = "/dev/ttyUSB0" entry = integration assert client.connect.call_count == 1 @@ -893,13 +891,6 @@ async def mock_backup_nvm_raw(): side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -927,10 +918,6 @@ async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): ) assert mock_usb_serial_by_id.call_count == 2 - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -947,7 +934,6 @@ async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): assert result["type"] is FlowResultType.FORM assert result["step_id"] == "instruct_unplug" - assert entry.unique_id == "4321" result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -962,6 +948,7 @@ async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): assert restart_addon.call_args == call("core_zwave_js") + version_info = get_server_version.return_value version_info.home_id = 5678 result = await hass.config_entries.flow.async_configure(result["flow_id"]) @@ -1024,13 +1011,6 @@ async def mock_backup_nvm_raw(): side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -1055,10 +1035,6 @@ async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): ) assert mock_usb_serial_by_id.call_count == 2 - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -3401,21 +3377,12 @@ async def test_reconfigure_migrate_low_sdk_version( @pytest.mark.usefixtures("supervisor", "addon_running") @pytest.mark.parametrize( ( - "reset_server_version_side_effect", - "reset_unique_id", "restore_server_version_side_effect", "final_unique_id", ), [ - (None, "4321", None, "3245146787"), - (aiohttp.ClientError("Boom"), "3245146787", None, "3245146787"), - (None, "4321", aiohttp.ClientError("Boom"), "5678"), - ( - aiohttp.ClientError("Boom"), - "3245146787", - aiohttp.ClientError("Boom"), - "5678", - ), + (None, "3245146787"), + (aiohttp.ClientError("Boom"), "5678"), ], ) async def test_reconfigure_migrate_with_addon( @@ -3428,15 +3395,11 @@ async def test_reconfigure_migrate_with_addon( addon_options: dict[str, Any], set_addon_options: AsyncMock, get_server_version: AsyncMock, - reset_server_version_side_effect: Exception | None, - reset_unique_id: str, restore_server_version_side_effect: Exception | None, final_unique_id: str, ) -> None: """Test migration flow with add-on.""" - get_server_version.side_effect = reset_server_version_side_effect version_info = get_server_version.return_value - version_info.home_id = 4321 entry = integration assert client.connect.call_count == 1 assert client.driver.controller.home_id == 3245146787 @@ -3494,13 +3457,6 @@ async def mock_backup_nvm_raw(): side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -3531,11 +3487,6 @@ async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -3552,7 +3503,6 @@ async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): assert result["type"] is FlowResultType.FORM assert result["step_id"] == "instruct_unplug" assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert entry.unique_id == reset_unique_id result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) @@ -3565,8 +3515,6 @@ async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): with pytest.raises(InInvalid): data_schema.schema[CONF_USB_PATH](addon_options["device"]) - # Reset side effect before starting the add-on. - get_server_version.side_effect = None version_info.home_id = 5678 result = await hass.config_entries.flow.async_configure( @@ -3646,156 +3594,6 @@ async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): assert client.driver.controller.home_id == 3245146787 -@pytest.mark.usefixtures("supervisor", "addon_running") -async def test_reconfigure_migrate_reset_driver_ready_timeout( - hass: HomeAssistant, - client: MagicMock, - integration: MockConfigEntry, - restart_addon: AsyncMock, - set_addon_options: AsyncMock, - get_server_version: AsyncMock, -) -> None: - """Test migration flow with driver ready timeout after controller reset.""" - version_info = get_server_version.return_value - version_info.home_id = 4321 - entry = integration - assert client.connect.call_count == 1 - hass.config_entries.async_update_entry( - entry, - unique_id="1234", - data={ - "url": "ws://localhost:3000", - "use_addon": True, - "usb_path": "/dev/ttyUSB0", - }, - ) - - async def mock_backup_nvm_raw(): - await asyncio.sleep(0) - client.driver.controller.emit( - "nvm backup progress", {"bytesRead": 100, "total": 200} - ) - return b"test_nvm_data" - - client.driver.controller.async_backup_nvm_raw = AsyncMock( - side_effect=mock_backup_nvm_raw - ) - - async def mock_reset_controller(): - await asyncio.sleep(0) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): - client.driver.controller.emit( - "nvm convert progress", - {"event": "nvm convert progress", "bytesRead": 100, "total": 200}, - ) - await asyncio.sleep(0) - client.driver.controller.emit( - "nvm restore progress", - {"event": "nvm restore progress", "bytesWritten": 100, "total": 200}, - ) - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.controller.async_restore_nvm = AsyncMock(side_effect=mock_restore_nvm) - - events = async_capture_events( - hass, data_entry_flow.EVENT_DATA_ENTRY_FLOW_PROGRESS_UPDATE - ) - - result = await entry.start_reconfigure_flow(hass) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "reconfigure" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_migrate"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - with ( - patch( - ("homeassistant.components.zwave_js.config_flow.DRIVER_READY_TIMEOUT"), - new=0, - ), - patch("pathlib.Path.write_bytes") as mock_file, - ): - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "backup_nvm" - - await hass.async_block_till_done() - assert client.driver.controller.async_backup_nvm_raw.call_count == 1 - assert mock_file.call_count == 1 - assert len(events) == 1 - assert events[0].data["progress"] == 0.5 - events.clear() - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "instruct_unplug" - assert entry.state is config_entries.ConfigEntryState.NOT_LOADED - assert entry.unique_id == "4321" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "choose_serial_port" - data_schema = result["data_schema"] - assert data_schema is not None - assert data_schema.schema[CONF_USB_PATH] - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], - user_input={ - CONF_USB_PATH: "/test", - }, - ) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "start_addon" - assert set_addon_options.call_args == call( - "core_zwave_js", AddonsOptions(config={"device": "/test"}) - ) - - await hass.async_block_till_done() - - assert restart_addon.call_args == call("core_zwave_js") - - version_info.home_id = 5678 - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "restore_nvm" - assert client.connect.call_count == 2 - - await hass.async_block_till_done() - assert client.connect.call_count == 4 - assert entry.state is config_entries.ConfigEntryState.LOADED - assert client.driver.controller.async_restore_nvm.call_count == 1 - assert len(events) == 2 - assert events[0].data["progress"] == 0.25 - assert events[1].data["progress"] == 0.75 - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "migration_successful" - assert entry.data["url"] == "ws://host1:3001" - assert entry.data["usb_path"] == "/test" - assert entry.data["use_addon"] is True - assert entry.unique_id == "5678" - assert "keep_old_devices" not in entry.data - - @pytest.mark.usefixtures("supervisor", "addon_running") async def test_reconfigure_migrate_restore_driver_ready_timeout( hass: HomeAssistant, @@ -3828,13 +3626,6 @@ async def mock_backup_nvm_raw(): side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): client.driver.controller.emit( "nvm convert progress", @@ -3861,11 +3652,6 @@ async def mock_restore_nvm(data: bytes, options: dict[str, bool] | None = None): result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -3960,11 +3746,6 @@ async def test_reconfigure_migrate_backup_failure( result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.ABORT assert result["reason"] == "backup_failed" assert "keep_old_devices" not in entry.data @@ -3998,11 +3779,6 @@ async def mock_backup_nvm_raw(): result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4040,13 +3816,6 @@ async def mock_backup_nvm_raw(): side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.MENU @@ -4056,11 +3825,6 @@ async def mock_reset_controller(): result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4124,12 +3888,6 @@ async def mock_backup_nvm_raw(): side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) client.driver.controller.async_restore_nvm = AsyncMock( side_effect=FailedCommand("test_error", "unknown_error") ) @@ -4143,11 +3901,6 @@ async def mock_reset_controller(): result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm" @@ -4242,106 +3995,6 @@ async def test_get_driver_failure_intent_migrate( assert "keep_old_devices" not in entry.data -async def test_get_driver_failure_instruct_unplug( - hass: HomeAssistant, - client: MagicMock, - integration: MockConfigEntry, -) -> None: - """Test get driver failure in instruct unplug step.""" - - async def mock_backup_nvm_raw(): - await asyncio.sleep(0) - client.driver.controller.emit( - "nvm backup progress", {"bytesRead": 100, "total": 200} - ) - return b"test_nvm_data" - - client.driver.controller.async_backup_nvm_raw = AsyncMock( - side_effect=mock_backup_nvm_raw - ) - entry = integration - hass.config_entries.async_update_entry( - entry, unique_id="1234", data={**entry.data, "use_addon": True} - ) - result = await entry.start_reconfigure_flow(hass) - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "reconfigure" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_migrate"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "backup_nvm" - - with patch("pathlib.Path.write_bytes") as mock_file: - await hass.async_block_till_done() - assert client.driver.controller.async_backup_nvm_raw.call_count == 1 - assert mock_file.call_count == 1 - - await hass.config_entries.async_unload(entry.entry_id) - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "config_entry_not_loaded" - - -async def test_hard_reset_failure( - hass: HomeAssistant, - integration: MockConfigEntry, - client: MagicMock, -) -> None: - """Test hard reset failure.""" - entry = integration - hass.config_entries.async_update_entry( - entry, unique_id="1234", data={**entry.data, "use_addon": True} - ) - - async def mock_backup_nvm_raw(): - await asyncio.sleep(0) - return b"test_nvm_data" - - client.driver.controller.async_backup_nvm_raw = AsyncMock( - side_effect=mock_backup_nvm_raw - ) - client.driver.async_hard_reset = AsyncMock( - side_effect=FailedCommand("test_error", "unknown_error") - ) - - result = await entry.start_reconfigure_flow(hass) - - assert result["type"] is FlowResultType.MENU - assert result["step_id"] == "reconfigure" - - result = await hass.config_entries.flow.async_configure( - result["flow_id"], {"next_step_id": "intent_migrate"} - ) - - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - - assert result["type"] is FlowResultType.SHOW_PROGRESS - assert result["step_id"] == "backup_nvm" - - with patch("pathlib.Path.write_bytes") as mock_file: - await hass.async_block_till_done() - assert client.driver.controller.async_backup_nvm_raw.call_count == 1 - assert mock_file.call_count == 1 - - result = await hass.config_entries.flow.async_configure(result["flow_id"]) - - assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "reset_failed" - - async def test_choose_serial_port_usb_ports_failure( hass: HomeAssistant, integration: MockConfigEntry, @@ -4361,13 +4014,6 @@ async def mock_backup_nvm_raw(): side_effect=mock_backup_nvm_raw ) - async def mock_reset_controller(): - client.driver.emit( - "driver ready", {"event": "driver ready", "source": "driver"} - ) - - client.driver.async_hard_reset = AsyncMock(side_effect=mock_reset_controller) - result = await entry.start_reconfigure_flow(hass) assert result["type"] is FlowResultType.MENU @@ -4377,11 +4023,6 @@ async def mock_reset_controller(): result["flow_id"], {"next_step_id": "intent_migrate"} ) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "intent_migrate" - - result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) - assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "backup_nvm"