diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d87187b55be9e8..982b73084f0abf 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,6 +1,6 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit - rev: v0.12.1 + rev: v0.13.0 hooks: - id: ruff-check args: diff --git a/.strict-typing b/.strict-typing index bf5b90b00917dd..78203703d1ac03 100644 --- a/.strict-typing +++ b/.strict-typing @@ -169,6 +169,7 @@ homeassistant.components.dnsip.* homeassistant.components.doorbird.* homeassistant.components.dormakaba_dkey.* homeassistant.components.downloader.* +homeassistant.components.droplet.* homeassistant.components.dsmr.* homeassistant.components.duckdns.* homeassistant.components.dunehd.* @@ -401,6 +402,7 @@ homeassistant.components.person.* homeassistant.components.pi_hole.* homeassistant.components.ping.* homeassistant.components.plugwise.* +homeassistant.components.portainer.* homeassistant.components.powerfox.* homeassistant.components.powerwall.* homeassistant.components.private_ble_device.* diff --git a/CODEOWNERS b/CODEOWNERS index 6a5e4ea437b2b3..c4ce561fdb62dc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -377,6 +377,8 @@ build.json @home-assistant/supervisor /tests/components/dremel_3d_printer/ @tkdrob /homeassistant/components/drop_connect/ @ChandlerSystems @pfrazer /tests/components/drop_connect/ @ChandlerSystems @pfrazer +/homeassistant/components/droplet/ @sarahseidman +/tests/components/droplet/ @sarahseidman /homeassistant/components/dsmr/ @Robbie1221 /tests/components/dsmr/ @Robbie1221 /homeassistant/components/dsmr_reader/ @sorted-bits @glodenox @erwindouna @@ -1189,6 +1191,8 @@ build.json @home-assistant/supervisor /tests/components/pooldose/ @lmaertin /homeassistant/components/poolsense/ @haemishkyd /tests/components/poolsense/ @haemishkyd +/homeassistant/components/portainer/ @erwindouna +/tests/components/portainer/ @erwindouna /homeassistant/components/powerfox/ @klaasnicolaas /tests/components/powerfox/ @klaasnicolaas /homeassistant/components/powerwall/ @bdraco @jrester @daniel-simpson diff --git a/homeassistant/components/accuweather/config_flow.py b/homeassistant/components/accuweather/config_flow.py index 3e65374f39104e..d16b9a1f77a115 100644 --- a/homeassistant/components/accuweather/config_flow.py +++ b/homeassistant/components/accuweather/config_flow.py @@ -50,6 +50,7 @@ async def async_step_user( await self.async_set_unique_id( accuweather.location_key, raise_on_progress=False ) + self._abort_if_unique_id_configured() return self.async_create_entry( title=user_input[CONF_NAME], data=user_input diff --git a/homeassistant/components/accuweather/const.py b/homeassistant/components/accuweather/const.py index e1dc4a9abcba9d..b9bf8df4556188 100644 --- a/homeassistant/components/accuweather/const.py +++ b/homeassistant/components/accuweather/const.py @@ -69,5 +69,5 @@ 4: "very_high", 5: "extreme", } -UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=40) +UPDATE_INTERVAL_OBSERVATION = timedelta(minutes=10) UPDATE_INTERVAL_DAILY_FORECAST = timedelta(hours=6) diff --git a/homeassistant/components/accuweather/manifest.json b/homeassistant/components/accuweather/manifest.json index 9f3c8c7932a79f..09ea76d022dcdd 100644 --- a/homeassistant/components/accuweather/manifest.json +++ b/homeassistant/components/accuweather/manifest.json @@ -7,6 +7,5 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["accuweather"], - "requirements": ["accuweather==4.2.1"], - "single_config_entry": true + "requirements": ["accuweather==4.2.1"] } diff --git a/homeassistant/components/accuweather/strings.json b/homeassistant/components/accuweather/strings.json index 19e52be1ce3587..cbda5f8989f168 100644 --- a/homeassistant/components/accuweather/strings.json +++ b/homeassistant/components/accuweather/strings.json @@ -17,6 +17,9 @@ "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_api_key": "[%key:common::config_flow::error::invalid_api_key%]", "requests_exceeded": "The allowed number of requests to the AccuWeather API has been exceeded. You have to wait or change the API key." + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_location%]" } }, "entity": { diff --git a/homeassistant/components/aladdin_connect/manifest.json b/homeassistant/components/aladdin_connect/manifest.json index 67c755e29a8ecd..8165ebd4ac9913 100644 --- a/homeassistant/components/aladdin_connect/manifest.json +++ b/homeassistant/components/aladdin_connect/manifest.json @@ -4,6 +4,11 @@ "codeowners": ["@swcloudgenie"], "config_flow": true, "dependencies": ["application_credentials"], + "dhcp": [ + { + "hostname": "gdocntl-*" + } + ], "documentation": "https://www.home-assistant.io/integrations/aladdin_connect", "integration_type": "hub", "iot_class": "cloud_polling", diff --git a/homeassistant/components/aladdin_connect/strings.json b/homeassistant/components/aladdin_connect/strings.json index ca13d004b625f3..7d673efd3cb6ea 100644 --- a/homeassistant/components/aladdin_connect/strings.json +++ b/homeassistant/components/aladdin_connect/strings.json @@ -7,6 +7,9 @@ "reauth_confirm": { "title": "[%key:common::config_flow::title::reauth%]", "description": "Aladdin Connect needs to re-authenticate your account" + }, + "oauth_discovery": { + "description": "Home Assistant has found an Aladdin Connect device on your network. Press **Submit** to continue setting up Aladdin Connect." } }, "abort": { diff --git a/homeassistant/components/alexa_devices/coordinator.py b/homeassistant/components/alexa_devices/coordinator.py index 7807c6f0efdbbc..3b14324fdb68c2 100644 --- a/homeassistant/components/alexa_devices/coordinator.py +++ b/homeassistant/components/alexa_devices/coordinator.py @@ -14,6 +14,7 @@ from homeassistant.const import CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from .const import _LOGGER, CONF_LOGIN_DATA, DOMAIN @@ -48,12 +49,13 @@ def __init__( entry.data[CONF_PASSWORD], entry.data[CONF_LOGIN_DATA], ) + self.previous_devices: set[str] = set() async def _async_update_data(self) -> dict[str, AmazonDevice]: """Update device data.""" try: await self.api.login_mode_stored_data() - return await self.api.get_devices_data() + data = await self.api.get_devices_data() except CannotConnect as err: raise UpdateFailed( translation_domain=DOMAIN, @@ -72,3 +74,31 @@ async def _async_update_data(self) -> dict[str, AmazonDevice]: translation_key="invalid_auth", translation_placeholders={"error": repr(err)}, ) from err + else: + current_devices = set(data.keys()) + if stale_devices := self.previous_devices - current_devices: + await self._async_remove_device_stale(stale_devices) + + self.previous_devices = current_devices + return data + + async def _async_remove_device_stale( + self, + stale_devices: set[str], + ) -> None: + """Remove stale device.""" + device_registry = dr.async_get(self.hass) + + for serial_num in stale_devices: + _LOGGER.debug( + "Detected change in devices: serial %s removed", + serial_num, + ) + device = device_registry.async_get_device( + identifiers={(DOMAIN, serial_num)} + ) + if device: + device_registry.async_update_device( + device_id=device.id, + remove_config_entry_id=self.config_entry.entry_id, + ) diff --git a/homeassistant/components/alexa_devices/quality_scale.yaml b/homeassistant/components/alexa_devices/quality_scale.yaml index e2583b29e94a5c..da48f366a6c245 100644 --- a/homeassistant/components/alexa_devices/quality_scale.yaml +++ b/homeassistant/components/alexa_devices/quality_scale.yaml @@ -64,9 +64,7 @@ rules: repair-issues: status: exempt comment: no known use cases for repair issues or flows, yet - stale-devices: - status: todo - comment: automate the cleanup process + stale-devices: done # Platinum async-dependency: done diff --git a/homeassistant/components/deconz/sensor.py b/homeassistant/components/deconz/sensor.py index d318db6e2bf8e6..955ea3df853e65 100644 --- a/homeassistant/components/deconz/sensor.py +++ b/homeassistant/components/deconz/sensor.py @@ -99,7 +99,7 @@ @dataclass(frozen=True, kw_only=True) -class DeconzSensorDescription(Generic[T], SensorEntityDescription): +class DeconzSensorDescription(SensorEntityDescription, Generic[T]): """Class describing deCONZ binary sensor entities.""" instance_check: type[T] | None = None diff --git a/homeassistant/components/droplet/__init__.py b/homeassistant/components/droplet/__init__.py new file mode 100644 index 00000000000000..47378742804f34 --- /dev/null +++ b/homeassistant/components/droplet/__init__.py @@ -0,0 +1,37 @@ +"""The Droplet integration.""" + +from __future__ import annotations + +import logging + +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +from .coordinator import DropletConfigEntry, DropletDataCoordinator + +PLATFORMS: list[Platform] = [ + Platform.SENSOR, +] + +logger = logging.getLogger(__name__) + + +async def async_setup_entry( + hass: HomeAssistant, config_entry: DropletConfigEntry +) -> bool: + """Set up Droplet from a config entry.""" + + droplet_coordinator = DropletDataCoordinator(hass, config_entry) + await droplet_coordinator.async_config_entry_first_refresh() + config_entry.runtime_data = droplet_coordinator + await hass.config_entries.async_forward_entry_setups(config_entry, PLATFORMS) + + return True + + +async def async_unload_entry( + hass: HomeAssistant, config_entry: DropletConfigEntry +) -> bool: + """Unload a config entry.""" + + return await hass.config_entries.async_unload_platforms(config_entry, PLATFORMS) diff --git a/homeassistant/components/droplet/config_flow.py b/homeassistant/components/droplet/config_flow.py new file mode 100644 index 00000000000000..c08e8c608e5ecd --- /dev/null +++ b/homeassistant/components/droplet/config_flow.py @@ -0,0 +1,118 @@ +"""Config flow for Droplet integration.""" + +from __future__ import annotations + +from typing import Any + +from pydroplet.droplet import DropletConnection, DropletDiscovery +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_CODE, CONF_DEVICE_ID, CONF_IP_ADDRESS, CONF_PORT +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .const import DOMAIN + + +class DropletConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle Droplet config flow.""" + + _droplet_discovery: DropletDiscovery + + async def async_step_zeroconf( + self, discovery_info: ZeroconfServiceInfo + ) -> ConfigFlowResult: + """Handle zeroconf discovery.""" + self._droplet_discovery = DropletDiscovery( + discovery_info.host, + discovery_info.port, + discovery_info.name, + ) + if not self._droplet_discovery.is_valid(): + return self.async_abort(reason="invalid_discovery_info") + + # In this case, device ID was part of the zeroconf discovery info + device_id: str = await self._droplet_discovery.get_device_id() + await self.async_set_unique_id(device_id) + + self._abort_if_unique_id_configured( + updates={CONF_IP_ADDRESS: self._droplet_discovery.host}, + ) + + self.context.update({"title_placeholders": {"name": device_id}}) + return await self.async_step_confirm() + + async def async_step_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Confirm the setup.""" + errors: dict[str, str] = {} + device_id: str = await self._droplet_discovery.get_device_id() + if user_input is not None: + # Test if we can connect before returning + session = async_get_clientsession(self.hass) + if await self._droplet_discovery.try_connect( + session, user_input[CONF_CODE] + ): + device_data = { + CONF_IP_ADDRESS: self._droplet_discovery.host, + CONF_PORT: self._droplet_discovery.port, + CONF_DEVICE_ID: device_id, + CONF_CODE: user_input[CONF_CODE], + } + + return self.async_create_entry( + title=device_id, + data=device_data, + ) + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="confirm", + data_schema=vol.Schema( + { + vol.Required(CONF_CODE): str, + } + ), + description_placeholders={ + "device_name": device_id, + }, + errors=errors, + ) + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a flow initialized by the user.""" + errors: dict[str, str] = {} + if user_input is not None: + self._droplet_discovery = DropletDiscovery( + user_input[CONF_IP_ADDRESS], DropletConnection.DEFAULT_PORT, "" + ) + session = async_get_clientsession(self.hass) + if await self._droplet_discovery.try_connect( + session, user_input[CONF_CODE] + ) and (device_id := await self._droplet_discovery.get_device_id()): + device_data = { + CONF_IP_ADDRESS: self._droplet_discovery.host, + CONF_PORT: self._droplet_discovery.port, + CONF_DEVICE_ID: device_id, + CONF_CODE: user_input[CONF_CODE], + } + await self.async_set_unique_id(device_id, raise_on_progress=False) + self._abort_if_unique_id_configured( + description_placeholders={CONF_DEVICE_ID: device_id}, + ) + + return self.async_create_entry( + title=device_id, + data=device_data, + ) + errors["base"] = "cannot_connect" + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_IP_ADDRESS): str, vol.Required(CONF_CODE): str} + ), + errors=errors, + ) diff --git a/homeassistant/components/droplet/const.py b/homeassistant/components/droplet/const.py new file mode 100644 index 00000000000000..3456b4fe432ad9 --- /dev/null +++ b/homeassistant/components/droplet/const.py @@ -0,0 +1,11 @@ +"""Constants for the droplet integration.""" + +CONNECT_DELAY = 5 + +DOMAIN = "droplet" +DEVICE_NAME = "Droplet" + +KEY_CURRENT_FLOW_RATE = "current_flow_rate" +KEY_VOLUME = "volume" +KEY_SIGNAL_QUALITY = "signal_quality" +KEY_SERVER_CONNECTIVITY = "server_connectivity" diff --git a/homeassistant/components/droplet/coordinator.py b/homeassistant/components/droplet/coordinator.py new file mode 100644 index 00000000000000..33a5468ebd8f2d --- /dev/null +++ b/homeassistant/components/droplet/coordinator.py @@ -0,0 +1,84 @@ +"""Droplet device data update coordinator object.""" + +from __future__ import annotations + +import asyncio +import logging +import time + +from pydroplet.droplet import Droplet + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_CODE, CONF_IP_ADDRESS, CONF_PORT +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import CONNECT_DELAY, DOMAIN + +VERSION_TIMEOUT = 5 + +_LOGGER = logging.getLogger(__name__) + +TIMEOUT = 1 + +type DropletConfigEntry = ConfigEntry[DropletDataCoordinator] + + +class DropletDataCoordinator(DataUpdateCoordinator[None]): + """Droplet device object.""" + + config_entry: DropletConfigEntry + + def __init__(self, hass: HomeAssistant, entry: DropletConfigEntry) -> None: + """Initialize the device.""" + super().__init__( + hass, _LOGGER, config_entry=entry, name=f"{DOMAIN}-{entry.unique_id}" + ) + self.droplet = Droplet( + host=entry.data[CONF_IP_ADDRESS], + port=entry.data[CONF_PORT], + token=entry.data[CONF_CODE], + session=async_get_clientsession(self.hass), + logger=_LOGGER, + ) + assert entry.unique_id is not None + self.unique_id = entry.unique_id + + async def _async_setup(self) -> None: + if not await self.setup(): + raise ConfigEntryNotReady("Device is offline") + + # Droplet should send its metadata within 5 seconds + end = time.time() + VERSION_TIMEOUT + while not self.droplet.version_info_available(): + await asyncio.sleep(TIMEOUT) + if time.time() > end: + _LOGGER.warning("Failed to get version info from Droplet") + return + + async def _async_update_data(self) -> None: + if not self.droplet.connected: + raise UpdateFailed( + translation_domain=DOMAIN, translation_key="connection_error" + ) + + async def setup(self) -> bool: + """Set up droplet client.""" + self.config_entry.async_on_unload(self.droplet.stop_listening) + self.config_entry.async_create_background_task( + self.hass, + self.droplet.listen_forever(CONNECT_DELAY, self.async_set_updated_data), + "droplet-listen", + ) + end = time.time() + CONNECT_DELAY + while time.time() < end: + if self.droplet.connected: + return True + await asyncio.sleep(TIMEOUT) + return False + + def get_availability(self) -> bool: + """Retrieve Droplet's availability status.""" + return self.droplet.get_availability() diff --git a/homeassistant/components/droplet/icons.json b/homeassistant/components/droplet/icons.json new file mode 100644 index 00000000000000..43e8795949067b --- /dev/null +++ b/homeassistant/components/droplet/icons.json @@ -0,0 +1,15 @@ +{ + "entity": { + "sensor": { + "current_flow_rate": { + "default": "mdi:chart-line" + }, + "server_connectivity": { + "default": "mdi:web" + }, + "signal_quality": { + "default": "mdi:waveform" + } + } + } +} diff --git a/homeassistant/components/droplet/manifest.json b/homeassistant/components/droplet/manifest.json new file mode 100644 index 00000000000000..bd5f1ba2a0bbb4 --- /dev/null +++ b/homeassistant/components/droplet/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "droplet", + "name": "Droplet", + "codeowners": ["@sarahseidman"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/droplet", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["pydroplet==2.3.2"], + "zeroconf": ["_droplet._tcp.local."] +} diff --git a/homeassistant/components/droplet/quality_scale.yaml b/homeassistant/components/droplet/quality_scale.yaml new file mode 100644 index 00000000000000..5ef0df9f3cc8ca --- /dev/null +++ b/homeassistant/components/droplet/quality_scale.yaml @@ -0,0 +1,72 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: | + No custom actions defined + appropriate-polling: + status: exempt + comment: | + No polling + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: todo + parallel-updates: todo + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: done + discovery: done + docs-data-update: done + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: done + docs-troubleshooting: todo + docs-use-cases: done + dynamic-devices: todo + entity-category: done + entity-device-class: done + entity-disabled-by-default: todo + entity-translations: done + exception-translations: todo + icon-translations: done + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/droplet/sensor.py b/homeassistant/components/droplet/sensor.py new file mode 100644 index 00000000000000..73420abc121535 --- /dev/null +++ b/homeassistant/components/droplet/sensor.py @@ -0,0 +1,131 @@ +"""Support for Droplet.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from datetime import datetime + +from pydroplet.droplet import Droplet + +from homeassistant.components.sensor import ( + SensorDeviceClass, + SensorEntity, + SensorEntityDescription, + SensorStateClass, +) +from homeassistant.const import EntityCategory, UnitOfVolume, UnitOfVolumeFlowRate +from homeassistant.core import HomeAssistant +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import ( + DOMAIN, + KEY_CURRENT_FLOW_RATE, + KEY_SERVER_CONNECTIVITY, + KEY_SIGNAL_QUALITY, + KEY_VOLUME, +) +from .coordinator import DropletConfigEntry, DropletDataCoordinator + +ML_L_CONVERSION = 1000 + + +@dataclass(kw_only=True, frozen=True) +class DropletSensorEntityDescription(SensorEntityDescription): + """Describes Droplet sensor entity.""" + + value_fn: Callable[[Droplet], float | str | None] + last_reset_fn: Callable[[Droplet], datetime | None] = lambda _: None + + +SENSORS: list[DropletSensorEntityDescription] = [ + DropletSensorEntityDescription( + key=KEY_CURRENT_FLOW_RATE, + translation_key=KEY_CURRENT_FLOW_RATE, + device_class=SensorDeviceClass.VOLUME_FLOW_RATE, + native_unit_of_measurement=UnitOfVolumeFlowRate.LITERS_PER_MINUTE, + suggested_unit_of_measurement=UnitOfVolumeFlowRate.GALLONS_PER_MINUTE, + suggested_display_precision=2, + state_class=SensorStateClass.MEASUREMENT, + value_fn=lambda device: device.get_flow_rate(), + ), + DropletSensorEntityDescription( + key=KEY_VOLUME, + device_class=SensorDeviceClass.WATER, + native_unit_of_measurement=UnitOfVolume.LITERS, + suggested_unit_of_measurement=UnitOfVolume.GALLONS, + suggested_display_precision=2, + state_class=SensorStateClass.TOTAL, + value_fn=lambda device: device.get_volume_delta() / ML_L_CONVERSION, + last_reset_fn=lambda device: device.get_volume_last_fetched(), + ), + DropletSensorEntityDescription( + key=KEY_SERVER_CONNECTIVITY, + translation_key=KEY_SERVER_CONNECTIVITY, + device_class=SensorDeviceClass.ENUM, + options=["connected", "connecting", "disconnected"], + value_fn=lambda device: device.get_server_status(), + entity_category=EntityCategory.DIAGNOSTIC, + ), + DropletSensorEntityDescription( + key=KEY_SIGNAL_QUALITY, + translation_key=KEY_SIGNAL_QUALITY, + device_class=SensorDeviceClass.ENUM, + options=["no_signal", "weak_signal", "strong_signal"], + value_fn=lambda device: device.get_signal_quality(), + entity_category=EntityCategory.DIAGNOSTIC, + ), +] + + +async def async_setup_entry( + hass: HomeAssistant, + config_entry: DropletConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up the Droplet sensors from config entry.""" + coordinator = config_entry.runtime_data + async_add_entities([DropletSensor(coordinator, sensor) for sensor in SENSORS]) + + +class DropletSensor(CoordinatorEntity[DropletDataCoordinator], SensorEntity): + """Representation of a Droplet.""" + + entity_description: DropletSensorEntityDescription + _attr_has_entity_name = True + + def __init__( + self, + coordinator: DropletDataCoordinator, + entity_description: DropletSensorEntityDescription, + ) -> None: + """Initialize the sensor.""" + super().__init__(coordinator) + self.entity_description = entity_description + + unique_id = coordinator.config_entry.unique_id + self._attr_unique_id = f"{unique_id}_{entity_description.key}" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, self.coordinator.unique_id)}, + manufacturer=self.coordinator.droplet.get_manufacturer(), + model=self.coordinator.droplet.get_model(), + sw_version=self.coordinator.droplet.get_fw_version(), + serial_number=self.coordinator.droplet.get_sn(), + ) + + @property + def available(self) -> bool: + """Get Droplet's availability.""" + return self.coordinator.get_availability() + + @property + def native_value(self) -> float | str | None: + """Return the value reported by the sensor.""" + return self.entity_description.value_fn(self.coordinator.droplet) + + @property + def last_reset(self) -> datetime | None: + """Return the last reset of the sensor, if applicable.""" + return self.entity_description.last_reset_fn(self.coordinator.droplet) diff --git a/homeassistant/components/droplet/strings.json b/homeassistant/components/droplet/strings.json new file mode 100644 index 00000000000000..dd3697708bf155 --- /dev/null +++ b/homeassistant/components/droplet/strings.json @@ -0,0 +1,46 @@ +{ + "config": { + "step": { + "user": { + "title": "Configure Droplet integration", + "description": "Manually enter Droplet's connection details.", + "data": { + "ip_address": "[%key:common::config_flow::data::ip%]", + "code": "Pairing code" + }, + "data_description": { + "ip_address": "Droplet's IP address", + "code": "Code from the Droplet app" + } + }, + "confirm": { + "title": "Confirm association", + "description": "Enter pairing code to connect to {device_name}.", + "data": { + "code": "[%key:component::droplet::config::step::user::data::code%]" + }, + "data_description": { + "code": "[%key:component::droplet::config::step::user::data_description::code%]" + } + } + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]" + } + }, + "entity": { + "sensor": { + "server_connectivity": { "name": "Server status" }, + "signal_quality": { "name": "Signal quality" }, + "current_flow_rate": { "name": "Flow rate" } + } + }, + "exceptions": { + "connection_error": { + "message": "Disconnected from Droplet" + } + } +} diff --git a/homeassistant/components/energy/sensor.py b/homeassistant/components/energy/sensor.py index 5aa710be19ec2e..9da5d0adfd5135 100644 --- a/homeassistant/components/energy/sensor.py +++ b/homeassistant/components/energy/sensor.py @@ -78,7 +78,7 @@ class SourceAdapter: """Adapter to allow sources and their flows to be used as sensors.""" source_type: Literal["grid", "gas", "water"] - flow_type: Literal["flow_from", "flow_to", None] + flow_type: Literal["flow_from", "flow_to"] | None stat_energy_key: Literal["stat_energy_from", "stat_energy_to"] total_money_key: Literal["stat_cost", "stat_compensation"] name_suffix: str diff --git a/homeassistant/components/feedreader/manifest.json b/homeassistant/components/feedreader/manifest.json index 088b116d167292..bd1a6f890a36aa 100644 --- a/homeassistant/components/feedreader/manifest.json +++ b/homeassistant/components/feedreader/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/feedreader", "iot_class": "cloud_polling", "loggers": ["feedparser", "sgmllib3k"], - "requirements": ["feedparser==6.0.11"] + "requirements": ["feedparser==6.0.12"] } diff --git a/homeassistant/components/heos/media_player.py b/homeassistant/components/heos/media_player.py index dd0cef0ec10ba8..b93f2e2b23447c 100644 --- a/homeassistant/components/heos/media_player.py +++ b/homeassistant/components/heos/media_player.py @@ -295,7 +295,7 @@ async def async_play_media( ) -> None: """Play a piece of media.""" if heos_source.is_media_uri(media_id): - media, data = heos_source.from_media_uri(media_id) + media, _data = heos_source.from_media_uri(media_id) if not isinstance(media, MediaItem): raise ValueError(f"Invalid media id '{media_id}'") await self._player.play_media( @@ -610,7 +610,7 @@ async def _async_browse_media_root(self) -> BrowseMedia: async def _async_browse_heos_media(self, media_content_id: str) -> BrowseMedia: """Browse a HEOS media item.""" - media, data = heos_source.from_media_uri(media_content_id) + media, _data = heos_source.from_media_uri(media_content_id) browse_media = _media_to_browse_media(media) try: browse_result = await self.coordinator.heos.browse_media(media) diff --git a/homeassistant/components/huawei_lte/quality_scale.yaml b/homeassistant/components/huawei_lte/quality_scale.yaml new file mode 100644 index 00000000000000..05cfb812da1262 --- /dev/null +++ b/homeassistant/components/huawei_lte/quality_scale.yaml @@ -0,0 +1,88 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: + status: done + comment: When we refactor to use a coordinator, be sure to place it in coordinator.py. + config-flow-test-coverage: + status: todo + comment: Use mock calls to check test_urlize_plain_host instead of user_input mod checks, combine test_show_set_form with a happy path flow, finish test_connection_errors and test_login_error with CREATE_ENTRY to check error recovery, move test_success to top and assert unique id in it, split test_reauth to two so we can test incorrect password recovery. + config-flow: + status: todo + comment: See if we can catch more specific exceptions in get_device_info. + dependency-transparency: + status: todo + comment: huawei-lte-api and stringcase are not built and published to PyPI from a public CI pipeline. + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: todo + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: + status: todo + comment: Kind of done, but to be reviewed, there's probably room for improvement. Maybe address this when converting to use a data update coordinator. See also https://github.com/home-assistant/core/issues/55495 + parallel-updates: todo + reauthentication-flow: done + test-coverage: + status: todo + comment: Get percentage up there, add missing actual action press invocations in button tests' suspended state tests, rename test_switch.py to test_switch.py + make its functions receive hass as first parameter where applicable. + + # Gold + devices: done + diagnostics: done + discovery-update-info: done + discovery: done + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: done + docs-supported-functions: + status: todo + comment: Some info exists, but there's room for improvement. + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: done + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: + status: todo + comment: Buttons and selects are lacking translations. + exception-translations: todo + icon-translations: + status: done + comment: Some use numeric state ranges or the like that are not available with icons.json state selectors. + reconfiguration-flow: todo + repair-issues: + status: todo + comment: Not sure if we have anything applicable. + stale-devices: + status: todo + comment: Not sure of applicability. + + # Platinum + async-dependency: + status: todo + comment: The integration is async, but underlying huawei-lte-api is not. + inject-websession: + status: exempt + comment: Underlying huawei-lte-api does not use aiohttp or httpx, so this does not apply. + strict-typing: + status: todo + comment: Integration is strictly typechecked already, and huawei-lte-api and url-normalize are in order. stringcase is not typed. diff --git a/homeassistant/components/husqvarna_automower_ble/config_flow.py b/homeassistant/components/husqvarna_automower_ble/config_flow.py index d6ec59f0ec9600..7d1977f930c1aa 100644 --- a/homeassistant/components/husqvarna_automower_ble/config_flow.py +++ b/homeassistant/components/husqvarna_automower_ble/config_flow.py @@ -148,7 +148,7 @@ async def probe_mower(self, device) -> str | None: assert self.address try: - (manufacturer, device_type, model) = await Mower( + (manufacturer, device_type, _model) = await Mower( channel_id, self.address ).probe_gatts(device) except (BleakError, TimeoutError) as exception: diff --git a/homeassistant/components/input_select/services.yaml b/homeassistant/components/input_select/services.yaml index 04a09e5366a872..668f5b97d2365e 100644 --- a/homeassistant/components/input_select/services.yaml +++ b/homeassistant/components/input_select/services.yaml @@ -17,7 +17,10 @@ select_option: required: true example: '"Item A"' selector: - text: + state: + hide_states: + - unavailable + - unknown select_previous: target: diff --git a/homeassistant/components/jewish_calendar/quality_scale.yaml b/homeassistant/components/jewish_calendar/quality_scale.yaml new file mode 100644 index 00000000000000..d9b77a053fc58e --- /dev/null +++ b/homeassistant/components/jewish_calendar/quality_scale.yaml @@ -0,0 +1,100 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: done + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: + status: exempt + comment: Local calculation does not require configuration. + test-before-setup: + status: exempt + comment: Local calculation does not require setup. + unique-config-entry: + status: done + comment: >- + The multiple config entry was removed due to multiple bugs in the + integration and low ROI. + We might consider revisiting this as an additional feature in the future + to allow supportong multiple languages for states, multiple locations and maybe + use it as a solution for multiple Zmanim configurations. + + # Silver + action-exceptions: done + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: + status: exempt + comment: This integration cannot be unavailable since it's a local calculation. + integration-owner: done + log-when-unavailable: + status: exempt + comment: This integration cannot be unavailable since it's a local calculation. + parallel-updates: done + reauthentication-flow: + status: exempt + comment: This integration does not require reauthentication, since it is a local calculation. + test-coverage: + status: todo + comment: |- + The following points should be addressed: + + * Don't use if-statements in tests (test_jewish_calendar_sensor, test_shabbat_times_sensor) + + # Gold + devices: done + diagnostics: done + discovery-update-info: + status: exempt + comment: This is a local calculation and does not require discovery. + discovery: + status: exempt + comment: This is a local calculation and does not require discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: + status: exempt + comment: No known limitations. + docs-supported-devices: + status: exempt + comment: This integration does not support physical devices. + docs-supported-functions: done + docs-troubleshooting: + status: exempt + comment: There are no more detailed troubleshooting instructions available. + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration does not have physical devices. + entity-category: done + entity-device-class: done + entity-disabled-by-default: done + entity-translations: done + exception-translations: done + icon-translations: done + reconfiguration-flow: done + repair-issues: + status: exempt + comment: There are no issues that can be repaired. + stale-devices: + status: exempt + comment: This integration does not have physical devices. + + # Platinum + async-dependency: todo + inject-websession: + status: exempt + comment: This integration does not require a web session. + strict-typing: done diff --git a/homeassistant/components/lidarr/coordinator.py b/homeassistant/components/lidarr/coordinator.py index 3f9d2be4bece4a..801d07fdc7d177 100644 --- a/homeassistant/components/lidarr/coordinator.py +++ b/homeassistant/components/lidarr/coordinator.py @@ -35,7 +35,7 @@ class LidarrData: type LidarrConfigEntry = ConfigEntry[LidarrData] -class LidarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): +class LidarrDataUpdateCoordinator(DataUpdateCoordinator[T], ABC, Generic[T]): """Data update coordinator for the Lidarr integration.""" config_entry: LidarrConfigEntry diff --git a/homeassistant/components/lightwave/sensor.py b/homeassistant/components/lightwave/sensor.py index 721c508dd99543..05dd04dd3cdcd7 100644 --- a/homeassistant/components/lightwave/sensor.py +++ b/homeassistant/components/lightwave/sensor.py @@ -53,7 +53,7 @@ def __init__(self, name, lwlink, serial): def update(self) -> None: """Communicate with a Lightwave RTF Proxy to get state.""" - (dummy_temp, dummy_targ, battery, dummy_output) = self._lwlink.read_trv_status( - self._serial + (_dummy_temp, _dummy_targ, battery, _dummy_output) = ( + self._lwlink.read_trv_status(self._serial) ) self._attr_native_value = battery diff --git a/homeassistant/components/lovelace/dashboard.py b/homeassistant/components/lovelace/dashboard.py index ddb54e7618f49d..4faf4f15b08c4d 100644 --- a/homeassistant/components/lovelace/dashboard.py +++ b/homeassistant/components/lovelace/dashboard.py @@ -215,12 +215,12 @@ async def async_get_info(self) -> dict[str, Any]: async def async_load(self, force: bool) -> dict[str, Any]: """Load config.""" - config, json = await self._async_load_or_cached(force) + config, _json = await self._async_load_or_cached(force) return config async def async_json(self, force: bool) -> json_fragment: """Return JSON representation of the config.""" - config, json = await self._async_load_or_cached(force) + _config, json = await self._async_load_or_cached(force) return json async def _async_load_or_cached( diff --git a/homeassistant/components/matter/light.py b/homeassistant/components/matter/light.py index a86938730c98c5..e01cc54f46d3fc 100644 --- a/homeassistant/components/matter/light.py +++ b/homeassistant/components/matter/light.py @@ -477,6 +477,7 @@ def _check_transition_blocklist(self) -> None: device_types.ColorTemperatureLight, device_types.DimmableLight, device_types.DimmablePlugInUnit, + device_types.MountedDimmableLoadControl, device_types.ExtendedColorLight, device_types.OnOffLight, device_types.DimmerSwitch, diff --git a/homeassistant/components/openuv/__init__.py b/homeassistant/components/openuv/__init__.py index 19e63747e4b549..6edb42427f31f1 100644 --- a/homeassistant/components/openuv/__init__.py +++ b/homeassistant/components/openuv/__init__.py @@ -30,7 +30,7 @@ DOMAIN, LOGGER, ) -from .coordinator import OpenUvCoordinator +from .coordinator import OpenUvCoordinator, OpenUvProtectionWindowCoordinator PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR] @@ -54,7 +54,7 @@ async def async_update_protection_data() -> dict[str, Any]: return await client.uv_protection_window(low=low, high=high) coordinators: dict[str, OpenUvCoordinator] = { - coordinator_name: OpenUvCoordinator( + coordinator_name: coordinator_cls( hass, entry=entry, name=coordinator_name, @@ -62,9 +62,13 @@ async def async_update_protection_data() -> dict[str, Any]: longitude=client.longitude, update_method=update_method, ) - for coordinator_name, update_method in ( - (DATA_UV, client.uv_index), - (DATA_PROTECTION_WINDOW, async_update_protection_data), + for coordinator_cls, coordinator_name, update_method in ( + (OpenUvCoordinator, DATA_UV, client.uv_index), + ( + OpenUvProtectionWindowCoordinator, + DATA_PROTECTION_WINDOW, + async_update_protection_data, + ), ) } diff --git a/homeassistant/components/openuv/binary_sensor.py b/homeassistant/components/openuv/binary_sensor.py index 09c9ab75192b6d..8165c66e7ddef9 100644 --- a/homeassistant/components/openuv/binary_sensor.py +++ b/homeassistant/components/openuv/binary_sensor.py @@ -7,7 +7,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from homeassistant.util.dt import as_local, parse_datetime, utcnow +from homeassistant.util.dt import as_local from .const import DATA_PROTECTION_WINDOW, DOMAIN, LOGGER, TYPE_PROTECTION_WINDOW from .coordinator import OpenUvCoordinator @@ -55,30 +55,27 @@ def _handle_coordinator_update(self) -> None: def _update_attrs(self) -> None: data = self.coordinator.data - for key in ("from_time", "to_time", "from_uv", "to_uv"): - if not data.get(key): - LOGGER.warning("Skipping update due to missing data: %s", key) - return + if not data: + LOGGER.warning("Skipping update due to missing data") + return if self.entity_description.key == TYPE_PROTECTION_WINDOW: - from_dt = parse_datetime(data["from_time"]) - to_dt = parse_datetime(data["to_time"]) - - if not from_dt or not to_dt: - LOGGER.warning( - "Unable to parse protection window datetimes: %s, %s", - data["from_time"], - data["to_time"], - ) - self._attr_is_on = False - return - - self._attr_is_on = from_dt <= utcnow() <= to_dt + self._attr_is_on = data.get("is_on", False) + self._attr_extra_state_attributes.update( + { + attr_key: data[data_key] + for attr_key, data_key in ( + (ATTR_PROTECTION_WINDOW_STARTING_UV, "from_uv"), + (ATTR_PROTECTION_WINDOW_ENDING_UV, "to_uv"), + ) + } + ) self._attr_extra_state_attributes.update( { - ATTR_PROTECTION_WINDOW_ENDING_TIME: as_local(to_dt), - ATTR_PROTECTION_WINDOW_ENDING_UV: data["to_uv"], - ATTR_PROTECTION_WINDOW_STARTING_UV: data["from_uv"], - ATTR_PROTECTION_WINDOW_STARTING_TIME: as_local(from_dt), + attr_key: as_local(data[data_key]) + for attr_key, data_key in ( + (ATTR_PROTECTION_WINDOW_STARTING_TIME, "from_time"), + (ATTR_PROTECTION_WINDOW_ENDING_TIME, "to_time"), + ) } ) diff --git a/homeassistant/components/openuv/coordinator.py b/homeassistant/components/openuv/coordinator.py index cc09161b3e9b03..eb5970edef5749 100644 --- a/homeassistant/components/openuv/coordinator.py +++ b/homeassistant/components/openuv/coordinator.py @@ -3,15 +3,18 @@ from __future__ import annotations from collections.abc import Awaitable, Callable +import datetime as dt from typing import Any, cast from pyopenuv.errors import InvalidApiKeyError, OpenUvError from homeassistant.config_entries import ConfigEntry -from homeassistant.core import HomeAssistant +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers import event as evt from homeassistant.helpers.debounce import Debouncer from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed +from homeassistant.util.dt import parse_datetime, utcnow from .const import LOGGER @@ -62,3 +65,90 @@ async def _async_update_data(self) -> dict[str, Any]: raise UpdateFailed(str(err)) from err return cast(dict[str, Any], data["result"]) + + +class OpenUvProtectionWindowCoordinator(OpenUvCoordinator): + """Define an OpenUV data coordinator for the protetction window.""" + + _reprocess_listener: CALLBACK_TYPE | None = None + + async def _async_update_data(self) -> dict[str, Any]: + data = await super()._async_update_data() + + for key in ("from_time", "to_time", "from_uv", "to_uv"): + if not data.get(key): + msg = "Skipping update due to missing data: {key}" + raise UpdateFailed(msg) + + data = self._parse_data(data) + data = self._process_data(data) + + self._schedule_reprocessing(data) + + return data + + def _parse_data(self, data: dict[str, Any]) -> dict[str, Any]: + """Parse & update datetime values in data.""" + + from_dt = parse_datetime(data["from_time"]) + to_dt = parse_datetime(data["to_time"]) + + if not from_dt or not to_dt: + LOGGER.warning( + "Unable to parse protection window datetimes: %s, %s", + data["from_time"], + data["to_time"], + ) + return {} + + return {**data, "from_time": from_dt, "to_time": to_dt} + + def _process_data(self, data: dict[str, Any]) -> dict[str, Any]: + """Process data for consumption by entities. + + Adds the `is_on` key to the resulting data. + """ + if not {"from_time", "to_time"}.issubset(data): + return {} + + return {**data, "is_on": data["from_time"] <= utcnow() <= data["to_time"]} + + def _schedule_reprocessing(self, data: dict[str, Any]) -> None: + """Schedule reprocessing of data.""" + + if not {"from_time", "to_time"}.issubset(data): + return + + now = utcnow() + from_dt = data["from_time"] + to_dt = data["to_time"] + reprocess_at: dt.datetime | None = None + + if from_dt and from_dt > now: + reprocess_at = from_dt + if to_dt and to_dt > now: + reprocess_at = to_dt if not reprocess_at else min(to_dt, reprocess_at) + + if reprocess_at: + self._async_cancel_reprocess_listener() + self._reprocess_listener = evt.async_track_point_in_utc_time( + self.hass, + self._async_handle_reprocess_event, + reprocess_at, + ) + + def _async_cancel_reprocess_listener(self) -> None: + """Cancel the reprocess event listener.""" + if self._reprocess_listener: + self._reprocess_listener() + self._reprocess_listener = None + + @callback + def _async_handle_reprocess_event(self, now: dt.datetime) -> None: + """Timer callback for reprocessing the data & updating listeners.""" + self._async_cancel_reprocess_listener() + + self.data = self._process_data(self.data) + self._schedule_reprocessing(self.data) + + self.async_update_listeners() diff --git a/homeassistant/components/portainer/__init__.py b/homeassistant/components/portainer/__init__.py new file mode 100644 index 00000000000000..602302a7c3a4d8 --- /dev/null +++ b/homeassistant/components/portainer/__init__.py @@ -0,0 +1,40 @@ +"""The Portainer integration.""" + +from __future__ import annotations + +from pyportainer import Portainer + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_HOST, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers.aiohttp_client import async_create_clientsession + +from .coordinator import PortainerCoordinator + +_PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR] + +type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] + + +async def async_setup_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool: + """Set up Portainer from a config entry.""" + + session = async_create_clientsession(hass) + client = Portainer( + api_url=entry.data[CONF_HOST], + api_key=entry.data[CONF_API_KEY], + session=session, + ) + + coordinator = PortainerCoordinator(hass, entry, client) + await coordinator.async_config_entry_first_refresh() + + entry.runtime_data = coordinator + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: PortainerConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, _PLATFORMS) diff --git a/homeassistant/components/portainer/binary_sensor.py b/homeassistant/components/portainer/binary_sensor.py new file mode 100644 index 00000000000000..5545cfc9b93126 --- /dev/null +++ b/homeassistant/components/portainer/binary_sensor.py @@ -0,0 +1,146 @@ +"""Binary sensor platform for Portainer.""" + +from __future__ import annotations + +from collections.abc import Callable +from dataclasses import dataclass +from typing import Any + +from pyportainer.models.docker import DockerContainer + +from homeassistant.components.binary_sensor import ( + BinarySensorDeviceClass, + BinarySensorEntity, + BinarySensorEntityDescription, +) +from homeassistant.const import EntityCategory +from homeassistant.core import HomeAssistant +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import PortainerConfigEntry +from .coordinator import PortainerCoordinator +from .entity import ( + PortainerContainerEntity, + PortainerCoordinatorData, + PortainerEndpointEntity, +) + + +@dataclass(frozen=True, kw_only=True) +class PortainerBinarySensorEntityDescription(BinarySensorEntityDescription): + """Class to hold Portainer binary sensor description.""" + + state_fn: Callable[[Any], bool] + + +CONTAINER_SENSORS: tuple[PortainerBinarySensorEntityDescription, ...] = ( + PortainerBinarySensorEntityDescription( + key="status", + translation_key="status", + state_fn=lambda data: data.state == "running", + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + +ENDPOINT_SENSORS: tuple[PortainerBinarySensorEntityDescription, ...] = ( + PortainerBinarySensorEntityDescription( + key="status", + translation_key="status", + state_fn=lambda data: data.endpoint.status == 1, # 1 = Running | 2 = Stopped + device_class=BinarySensorDeviceClass.RUNNING, + entity_category=EntityCategory.DIAGNOSTIC, + ), +) + + +async def async_setup_entry( + hass: HomeAssistant, + entry: PortainerConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Portainer binary sensors.""" + coordinator = entry.runtime_data + entities: list[BinarySensorEntity] = [] + + for endpoint in coordinator.data.values(): + entities.extend( + PortainerEndpointSensor( + coordinator, + entity_description, + endpoint, + ) + for entity_description in ENDPOINT_SENSORS + ) + + entities.extend( + PortainerContainerSensor( + coordinator, + entity_description, + container, + endpoint, + ) + for container in endpoint.containers.values() + for entity_description in CONTAINER_SENSORS + ) + + async_add_entities(entities) + + +class PortainerEndpointSensor(PortainerEndpointEntity, BinarySensorEntity): + """Representation of a Portainer endpoint binary sensor entity.""" + + entity_description: PortainerBinarySensorEntityDescription + + def __init__( + self, + coordinator: PortainerCoordinator, + entity_description: PortainerBinarySensorEntityDescription, + device_info: PortainerCoordinatorData, + ) -> None: + """Initialize Portainer endpoint binary sensor entity.""" + self.entity_description = entity_description + super().__init__(device_info, coordinator) + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}" + + @property + def available(self) -> bool: + """Return if the device is available.""" + return super().available and self.device_id in self.coordinator.data + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.state_fn(self.coordinator.data[self.device_id]) + + +class PortainerContainerSensor(PortainerContainerEntity, BinarySensorEntity): + """Representation of a Portainer container sensor.""" + + entity_description: PortainerBinarySensorEntityDescription + + def __init__( + self, + coordinator: PortainerCoordinator, + entity_description: PortainerBinarySensorEntityDescription, + device_info: DockerContainer, + via_device: PortainerCoordinatorData, + ) -> None: + """Initialize the Portainer container sensor.""" + self.entity_description = entity_description + super().__init__(device_info, coordinator, via_device) + + self._attr_unique_id = f"{coordinator.config_entry.entry_id}_{device_info.id}_{entity_description.key}" + + @property + def available(self) -> bool: + """Return if the device is available.""" + return super().available and self.endpoint_id in self.coordinator.data + + @property + def is_on(self) -> bool | None: + """Return true if the binary sensor is on.""" + return self.entity_description.state_fn( + self.coordinator.data[self.endpoint_id].containers[self.device_id] + ) diff --git a/homeassistant/components/portainer/config_flow.py b/homeassistant/components/portainer/config_flow.py new file mode 100644 index 00000000000000..9cf9598cc9560e --- /dev/null +++ b/homeassistant/components/portainer/config_flow.py @@ -0,0 +1,95 @@ +"""Config flow for the portainer integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from pyportainer import ( + Portainer, + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers.aiohttp_client import async_get_clientsession + +from .const import DOMAIN + +_LOGGER = logging.getLogger(__name__) +STEP_USER_DATA_SCHEMA = vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_API_KEY): str, + } +) + + +async def _validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: + """Validate the user input allows us to connect.""" + + client = Portainer( + api_url=data[CONF_HOST], + api_key=data[CONF_API_KEY], + session=async_get_clientsession(hass), + ) + try: + await client.get_endpoints() + except PortainerAuthenticationError: + raise InvalidAuth from None + except PortainerConnectionError as err: + raise CannotConnect from err + except PortainerTimeoutError as err: + raise PortainerTimeout from err + + _LOGGER.debug("Connected to Portainer API: %s", data[CONF_HOST]) + + +class PortainerConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for Portainer.""" + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + self._async_abort_entries_match({CONF_HOST: user_input[CONF_HOST]}) + try: + await _validate_input(self.hass, user_input) + except CannotConnect: + errors["base"] = "cannot_connect" + except InvalidAuth: + errors["base"] = "invalid_auth" + except PortainerTimeout: + errors["base"] = "timeout_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + await self.async_set_unique_id(user_input[CONF_API_KEY]) + self._abort_if_unique_id_configured() + return self.async_create_entry( + title=user_input[CONF_HOST], data=user_input + ) + + return self.async_show_form( + step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) + + +class CannotConnect(HomeAssistantError): + """Error to indicate we cannot connect.""" + + +class InvalidAuth(HomeAssistantError): + """Error to indicate there is invalid auth.""" + + +class PortainerTimeout(HomeAssistantError): + """Error to indicate a timeout occurred.""" diff --git a/homeassistant/components/portainer/const.py b/homeassistant/components/portainer/const.py new file mode 100644 index 00000000000000..b9d29a468aff43 --- /dev/null +++ b/homeassistant/components/portainer/const.py @@ -0,0 +1,4 @@ +"""Constants for the Portainer integration.""" + +DOMAIN = "portainer" +DEFAULT_NAME = "Portainer" diff --git a/homeassistant/components/portainer/coordinator.py b/homeassistant/components/portainer/coordinator.py new file mode 100644 index 00000000000000..988ae319bab146 --- /dev/null +++ b/homeassistant/components/portainer/coordinator.py @@ -0,0 +1,137 @@ +"""Data Update Coordinator for Portainer.""" + +from __future__ import annotations + +from dataclasses import dataclass +from datetime import timedelta +import logging + +from pyportainer import ( + Portainer, + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) +from pyportainer.models.docker import DockerContainer +from pyportainer.models.portainer import Endpoint + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryError, ConfigEntryNotReady +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed + +from .const import DOMAIN + +type PortainerConfigEntry = ConfigEntry[PortainerCoordinator] + +_LOGGER = logging.getLogger(__name__) + +DEFAULT_SCAN_INTERVAL = timedelta(seconds=60) + + +@dataclass +class PortainerCoordinatorData: + """Data class for Portainer Coordinator.""" + + id: int + name: str | None + endpoint: Endpoint + containers: dict[str, DockerContainer] + + +class PortainerCoordinator(DataUpdateCoordinator[dict[int, PortainerCoordinatorData]]): + """Data Update Coordinator for Portainer.""" + + config_entry: PortainerConfigEntry + + def __init__( + self, + hass: HomeAssistant, + config_entry: PortainerConfigEntry, + portainer: Portainer, + ) -> None: + """Initialize the Portainer Data Update Coordinator.""" + super().__init__( + hass, + _LOGGER, + config_entry=config_entry, + name=DOMAIN, + update_interval=DEFAULT_SCAN_INTERVAL, + ) + self.portainer = portainer + + async def _async_setup(self) -> None: + """Set up the Portainer Data Update Coordinator.""" + try: + await self.portainer.get_endpoints() + except PortainerAuthenticationError as err: + raise ConfigEntryError( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except PortainerConnectionError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except PortainerTimeoutError as err: + raise ConfigEntryNotReady( + translation_domain=DOMAIN, + translation_key="timeout_connect", + translation_placeholders={"error": repr(err)}, + ) from err + + async def _async_update_data(self) -> dict[int, PortainerCoordinatorData]: + """Fetch data from Portainer API.""" + _LOGGER.debug( + "Fetching data from Portainer API: %s", self.config_entry.data[CONF_HOST] + ) + + try: + endpoints = await self.portainer.get_endpoints() + except PortainerAuthenticationError as err: + _LOGGER.error("Authentication error: %s", repr(err)) + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + except PortainerConnectionError as err: + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + else: + _LOGGER.debug("Fetched endpoints: %s", endpoints) + + mapped_endpoints: dict[int, PortainerCoordinatorData] = {} + for endpoint in endpoints: + try: + containers = await self.portainer.get_containers(endpoint.id) + except PortainerConnectionError as err: + _LOGGER.exception("Connection error") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="cannot_connect", + translation_placeholders={"error": repr(err)}, + ) from err + except PortainerAuthenticationError as err: + _LOGGER.exception("Authentication error") + raise UpdateFailed( + translation_domain=DOMAIN, + translation_key="invalid_auth", + translation_placeholders={"error": repr(err)}, + ) from err + + mapped_endpoints[endpoint.id] = PortainerCoordinatorData( + id=endpoint.id, + name=endpoint.name, + endpoint=endpoint, + containers={container.id: container for container in containers}, + ) + + return mapped_endpoints diff --git a/homeassistant/components/portainer/entity.py b/homeassistant/components/portainer/entity.py new file mode 100644 index 00000000000000..ecabafc4663f48 --- /dev/null +++ b/homeassistant/components/portainer/entity.py @@ -0,0 +1,73 @@ +"""Base class for Portainer entities.""" + +from pyportainer.models.docker import DockerContainer + +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DEFAULT_NAME, DOMAIN +from .coordinator import PortainerCoordinator, PortainerCoordinatorData + + +class PortainerCoordinatorEntity(CoordinatorEntity[PortainerCoordinator]): + """Base class for Portainer entities.""" + + _attr_has_entity_name = True + + +class PortainerEndpointEntity(PortainerCoordinatorEntity): + """Base implementation for Portainer endpoint.""" + + def __init__( + self, + device_info: PortainerCoordinatorData, + coordinator: PortainerCoordinator, + ) -> None: + """Initialize a Portainer endpoint.""" + super().__init__(coordinator) + self._device_info = device_info + self.device_id = device_info.endpoint.id + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, f"{coordinator.config_entry.entry_id}_{self.device_id}") + }, + manufacturer=DEFAULT_NAME, + model="Endpoint", + name=device_info.endpoint.name, + ) + + +class PortainerContainerEntity(PortainerCoordinatorEntity): + """Base implementation for Portainer container.""" + + def __init__( + self, + device_info: DockerContainer, + coordinator: PortainerCoordinator, + via_device: PortainerCoordinatorData, + ) -> None: + """Initialize a Portainer container.""" + super().__init__(coordinator) + self._device_info = device_info + self.device_id = self._device_info.id + self.endpoint_id = via_device.endpoint.id + + device_name = ( + self._device_info.names[0].replace("/", " ").strip() + if self._device_info.names + else None + ) + + self._attr_device_info = DeviceInfo( + identifiers={ + (DOMAIN, f"{self.coordinator.config_entry.entry_id}_{self.device_id}") + }, + manufacturer=DEFAULT_NAME, + model="Container", + name=device_name, + via_device=( + DOMAIN, + f"{self.coordinator.config_entry.entry_id}_{self.endpoint_id}", + ), + translation_key=None if device_name else "unknown_container", + ) diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json new file mode 100644 index 00000000000000..bb285dd37b9062 --- /dev/null +++ b/homeassistant/components/portainer/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "portainer", + "name": "Portainer", + "codeowners": ["@erwindouna"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/portainer", + "iot_class": "local_polling", + "quality_scale": "bronze", + "requirements": ["pyportainer==0.1.7"] +} diff --git a/homeassistant/components/portainer/quality_scale.yaml b/homeassistant/components/portainer/quality_scale.yaml new file mode 100644 index 00000000000000..fd13fd350658b4 --- /dev/null +++ b/homeassistant/components/portainer/quality_scale.yaml @@ -0,0 +1,80 @@ +rules: + # Bronze + action-setup: done + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: | + No custom actions are defined. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: + status: exempt + comment: | + No explicit event subscriptions. + entity-unique-id: done + has-entity-name: done + runtime-data: done + test-before-configure: done + test-before-setup: done + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: | + No custom actions are defined. + config-entry-unloading: done + docs-configuration-parameters: done + docs-installation-parameters: done + entity-unavailable: done + integration-owner: done + log-when-unavailable: done + parallel-updates: + status: exempt + comment: | + No explicit parallel updates are defined. + reauthentication-flow: + status: todo + comment: | + No reauthentication flow is defined. It will be done in a next iteration. + test-coverage: done + # Gold + devices: done + diagnostics: todo + discovery-update-info: + status: exempt + comment: | + No discovery is implemented, since it's software based. + discovery: + status: exempt + comment: | + No discovery is implemented, since it's software based. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: todo + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: todo + entity-category: todo + entity-device-class: todo + entity-disabled-by-default: todo + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: todo + + # Platinum + async-dependency: todo + inject-websession: done + strict-typing: done diff --git a/homeassistant/components/portainer/strings.json b/homeassistant/components/portainer/strings.json new file mode 100644 index 00000000000000..798840e806211a --- /dev/null +++ b/homeassistant/components/portainer/strings.json @@ -0,0 +1,49 @@ +{ + "config": { + "step": { + "user": { + "data": { + "host": "[%key:common::config_flow::data::host%]", + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "host": "The host/URL, including the port, of your Portainer instance", + "api_key": "The API key for authenticating with Portainer" + }, + "description": "You can create an API key in the Portainer UI. Go to **My account > API keys** and select **Add API key**" + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "timeout_connect": "[%key:common::config_flow::error::timeout_connect%]", + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + }, + "device": { + "unknown_container": { + "name": "Unknown container" + } + }, + "entity": { + "binary_sensor": { + "status": { + "name": "Status" + } + } + }, + "exceptions": { + "cannot_connect": { + "message": "An error occurred while trying to connect to the Portainer instance: {error}" + }, + "invalid_auth": { + "message": "An error occurred while trying authenticate: {error}" + }, + "timeout_connect": { + "message": "A timeout occurred while trying to connect to the Portainer instance: {error}" + } + } +} diff --git a/homeassistant/components/qbus/entity.py b/homeassistant/components/qbus/entity.py index f7205a85c005ff..3f504b4c2fbcad 100644 --- a/homeassistant/components/qbus/entity.py +++ b/homeassistant/components/qbus/entity.py @@ -78,7 +78,7 @@ def create_unique_id(serial_number: str, suffix: str) -> str: return f"ctd_{serial_number}_{suffix}" -class QbusEntity(Entity, Generic[StateT], ABC): +class QbusEntity(Entity, ABC, Generic[StateT]): """Representation of a Qbus entity.""" _state_cls: type[StateT] = cast(type[StateT], QbusMqttState) diff --git a/homeassistant/components/radarr/coordinator.py b/homeassistant/components/radarr/coordinator.py index d343675d7ea025..72789658649e2c 100644 --- a/homeassistant/components/radarr/coordinator.py +++ b/homeassistant/components/radarr/coordinator.py @@ -57,7 +57,7 @@ class RadarrEvent(CalendarEvent, RadarrEventMixIn): """A class to describe a Radarr calendar event.""" -class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], Generic[T], ABC): +class RadarrDataUpdateCoordinator(DataUpdateCoordinator[T], ABC, Generic[T]): """Data update coordinator for the Radarr integration.""" config_entry: RadarrConfigEntry diff --git a/homeassistant/components/reolink/__init__.py b/homeassistant/components/reolink/__init__.py index 42a29ee6ef45cf..81e000d8a75567 100644 --- a/homeassistant/components/reolink/__init__.py +++ b/homeassistant/components/reolink/__init__.py @@ -322,7 +322,7 @@ async def async_remove_config_entry_device( ) -> bool: """Remove a device from a config entry.""" host: ReolinkHost = config_entry.runtime_data.host - (device_uid, ch, is_chime) = get_device_uid_and_ch(device, host) + (_device_uid, ch, is_chime) = get_device_uid_and_ch(device, host) if is_chime: await host.api.get_state(cmd="GetDingDongList") @@ -431,7 +431,9 @@ def migrate_entity_ids( if (DOMAIN, host.unique_id) in device.identifiers: remove_ids = True # NVR/Hub in identifiers, keep that one, remove others for old_id in device.identifiers: - (old_device_uid, old_ch, old_is_chime) = get_device_uid_and_ch(old_id, host) + (old_device_uid, _old_ch, _old_is_chime) = get_device_uid_and_ch( + old_id, host + ) if ( not old_device_uid or old_device_uid[0] != host.unique_id diff --git a/homeassistant/components/reolink/services.py b/homeassistant/components/reolink/services.py index 352ebb4ef19ba8..33347170d11f25 100644 --- a/homeassistant/components/reolink/services.py +++ b/homeassistant/components/reolink/services.py @@ -46,7 +46,7 @@ async def _async_play_chime(service_call: ServiceCall) -> None: translation_placeholders={"service_name": "play_chime"}, ) host: ReolinkHost = config_entry.runtime_data.host - (device_uid, chime_id, is_chime) = get_device_uid_and_ch(device, host) + (_device_uid, chime_id, is_chime) = get_device_uid_and_ch(device, host) chime: Chime | None = host.api.chime(chime_id) if not is_chime or chime is None: raise ServiceValidationError( diff --git a/homeassistant/components/reolink/views.py b/homeassistant/components/reolink/views.py index 7f062055f7e0e3..3a160ce3f8a03d 100644 --- a/homeassistant/components/reolink/views.py +++ b/homeassistant/components/reolink/views.py @@ -79,7 +79,7 @@ async def get( return web.Response(body=err_str, status=HTTPStatus.BAD_REQUEST) try: - mime_type, reolink_url = await host.api.get_vod_source( + _mime_type, reolink_url = await host.api.get_vod_source( ch, filename_decoded, stream_res, VodRequestType(vod_type) ) except ReolinkError as err: diff --git a/homeassistant/components/select/services.yaml b/homeassistant/components/select/services.yaml index dc6d4c6815a562..4f655422f6fb96 100644 --- a/homeassistant/components/select/services.yaml +++ b/homeassistant/components/select/services.yaml @@ -27,7 +27,10 @@ select_option: required: true example: '"Item A"' selector: - text: + state: + hide_states: + - unavailable + - unknown select_previous: target: diff --git a/homeassistant/components/shelly/icons.json b/homeassistant/components/shelly/icons.json index 08b269a73c56f0..6760400a1f73b3 100644 --- a/homeassistant/components/shelly/icons.json +++ b/homeassistant/components/shelly/icons.json @@ -43,6 +43,9 @@ }, "valve_status": { "default": "mdi:valve" + }, + "illuminance_level": { + "default": "mdi:brightness-5" } }, "switch": { diff --git a/homeassistant/components/shelly/sensor.py b/homeassistant/components/shelly/sensor.py index b5cbeb3da5f4ea..bd94ea0c33e44c 100644 --- a/homeassistant/components/shelly/sensor.py +++ b/homeassistant/components/shelly/sensor.py @@ -1421,6 +1421,14 @@ def __init__( entity_category=EntityCategory.DIAGNOSTIC, entity_class=RpcBluTrvSensor, ), + "illuminance_illumination": RpcSensorDescription( + key="illuminance", + sub_key="illumination", + name="Illuminance Level", + translation_key="illuminance_level", + device_class=SensorDeviceClass.ENUM, + options=["dark", "twilight", "bright"], + ), } diff --git a/homeassistant/components/shelly/strings.json b/homeassistant/components/shelly/strings.json index e1f817ba1a8c72..0c1d7051275021 100644 --- a/homeassistant/components/shelly/strings.json +++ b/homeassistant/components/shelly/strings.json @@ -193,6 +193,13 @@ "opened": "Opened", "opening": "[%key:common::state::opening%]" } + }, + "illuminance_level": { + "state": { + "dark": "Dark", + "twilight": "Twilight", + "bright": "Bright" + } } } }, diff --git a/homeassistant/components/sma/config_flow.py b/homeassistant/components/sma/config_flow.py index e08b9ade9fc8dc..66dd9c9993d02f 100644 --- a/homeassistant/components/sma/config_flow.py +++ b/homeassistant/components/sma/config_flow.py @@ -151,7 +151,7 @@ async def async_step_reauth_confirm( errors: dict[str, str] = {} if user_input is not None: reauth_entry = self._get_reauth_entry() - errors, device_info = await self._handle_user_input( + errors, _device_info = await self._handle_user_input( user_input={ **reauth_entry.data, CONF_PASSWORD: user_input[CONF_PASSWORD], @@ -224,7 +224,7 @@ async def async_step_discovery_confirm( """Confirm discovery.""" errors: dict[str, str] = {} if user_input is not None: - errors, device_info = await self._handle_user_input( + errors, _device_info = await self._handle_user_input( user_input=user_input, discovery=True ) diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index acf37fe916b92b..08df5dc50f0850 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -95,6 +95,8 @@ SupportedModels.EVAPORATIVE_HUMIDIFIER: [Platform.HUMIDIFIER, Platform.SENSOR], SupportedModels.FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR], SupportedModels.STRIP_LIGHT_3.value: [Platform.LIGHT, Platform.SENSOR], + SupportedModels.RGBICWW_FLOOR_LAMP.value: [Platform.LIGHT, Platform.SENSOR], + SupportedModels.RGBICWW_STRIP_LIGHT.value: [Platform.LIGHT, Platform.SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, @@ -123,6 +125,8 @@ SupportedModels.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier, SupportedModels.FLOOR_LAMP.value: switchbot.SwitchbotStripLight3, SupportedModels.STRIP_LIGHT_3.value: switchbot.SwitchbotStripLight3, + SupportedModels.RGBICWW_FLOOR_LAMP.value: switchbot.SwitchbotRgbicLight, + SupportedModels.RGBICWW_STRIP_LIGHT.value: switchbot.SwitchbotRgbicLight, } diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index c57b8d467cc62f..5cdb3d9dd4e637 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -51,6 +51,8 @@ class SupportedModels(StrEnum): EVAPORATIVE_HUMIDIFIER = "evaporative_humidifier" FLOOR_LAMP = "floor_lamp" STRIP_LIGHT_3 = "strip_light_3" + RGBICWW_STRIP_LIGHT = "rgbicww_strip_light" + RGBICWW_FLOOR_LAMP = "rgbicww_floor_lamp" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -81,6 +83,8 @@ class SupportedModels(StrEnum): SwitchbotModel.EVAPORATIVE_HUMIDIFIER: SupportedModels.EVAPORATIVE_HUMIDIFIER, SwitchbotModel.FLOOR_LAMP: SupportedModels.FLOOR_LAMP, SwitchbotModel.STRIP_LIGHT_3: SupportedModels.STRIP_LIGHT_3, + SwitchbotModel.RGBICWW_STRIP_LIGHT: SupportedModels.RGBICWW_STRIP_LIGHT, + SwitchbotModel.RGBICWW_FLOOR_LAMP: SupportedModels.RGBICWW_FLOOR_LAMP, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -112,6 +116,8 @@ class SupportedModels(StrEnum): SwitchbotModel.EVAPORATIVE_HUMIDIFIER, SwitchbotModel.FLOOR_LAMP, SwitchbotModel.STRIP_LIGHT_3, + SwitchbotModel.RGBICWW_STRIP_LIGHT, + SwitchbotModel.RGBICWW_FLOOR_LAMP, } ENCRYPTED_SWITCHBOT_MODEL_TO_CLASS: dict[ @@ -128,6 +134,8 @@ class SupportedModels(StrEnum): SwitchbotModel.EVAPORATIVE_HUMIDIFIER: switchbot.SwitchbotEvaporativeHumidifier, SwitchbotModel.FLOOR_LAMP: switchbot.SwitchbotStripLight3, SwitchbotModel.STRIP_LIGHT_3: switchbot.SwitchbotStripLight3, + SwitchbotModel.RGBICWW_STRIP_LIGHT: switchbot.SwitchbotRgbicLight, + SwitchbotModel.RGBICWW_FLOOR_LAMP: switchbot.SwitchbotRgbicLight, } HASS_SENSOR_TYPE_TO_SWITCHBOT_MODEL = { diff --git a/homeassistant/components/switchbot/icons.json b/homeassistant/components/switchbot/icons.json index cf9217bf70b261..b04c04188d11ec 100644 --- a/homeassistant/components/switchbot/icons.json +++ b/homeassistant/components/switchbot/icons.json @@ -99,7 +99,30 @@ "rose": "mdi:flower", "colorful": "mdi:looks", "flickering": "mdi:led-strip-variant", - "breathing": "mdi:heart-pulse" + "breathing": "mdi:heart-pulse", + "romance": "mdi:heart-outline", + "energy": "mdi:run", + "heartbeat": "mdi:heart-pulse", + "party": "mdi:party-popper", + "dynamic": "mdi:palette", + "mystery": "mdi:alien-outline", + "lightning": "mdi:flash-outline", + "rock": "mdi:guitar-electric", + "starlight": "mdi:creation", + "valentine_day": "mdi:emoticon-kiss-outline", + "dream": "mdi:sleep", + "alarm": "mdi:alarm-light", + "fireworks": "mdi:firework", + "waves": "mdi:waves", + "rainbow": "mdi:looks", + "game": "mdi:gamepad-variant-outline", + "meditation": "mdi:meditation", + "starlit_sky": "mdi:weather-night", + "sleep": "mdi:power-sleep", + "movie": "mdi:popcorn", + "sunrise": "mdi:weather-sunset-up", + "new_year": "mdi:glass-wine", + "cherry_blossom": "mdi:flower-outline" } } } diff --git a/homeassistant/components/switchbot/manifest.json b/homeassistant/components/switchbot/manifest.json index 175aacf5d4c014..d57a41e00ef5d5 100644 --- a/homeassistant/components/switchbot/manifest.json +++ b/homeassistant/components/switchbot/manifest.json @@ -41,5 +41,5 @@ "iot_class": "local_push", "loggers": ["switchbot"], "quality_scale": "gold", - "requirements": ["PySwitchbot==0.69.0"] + "requirements": ["PySwitchbot==0.70.0"] } diff --git a/homeassistant/components/switchbot/strings.json b/homeassistant/components/switchbot/strings.json index 35482016e90dc6..961204ee88d9e6 100644 --- a/homeassistant/components/switchbot/strings.json +++ b/homeassistant/components/switchbot/strings.json @@ -270,7 +270,30 @@ "rose": "Rose", "colorful": "Colorful", "flickering": "Flickering", - "breathing": "Breathing" + "breathing": "Breathing", + "romance": "Romance", + "energy": "Energy", + "heartbeat": "Heartbeat", + "party": "Party", + "dynamic": "Dynamic", + "mystery": "Mystery", + "lightning": "Lightning", + "rock": "Rock", + "starlight": "Starlight", + "valentine_day": "Valentine's Day", + "dream": "Dream", + "alarm": "Alarm", + "fireworks": "Fireworks", + "waves": "Waves", + "rainbow": "Rainbow", + "game": "Game", + "meditation": "Meditation", + "starlit_sky": "Starlit Sky", + "sleep": "Sleep", + "movie": "Movie", + "sunrise": "Sunrise", + "new_year": "New Year", + "cherry_blossom": "Cherry Blossom" } } } diff --git a/homeassistant/components/teslemetry/services.py b/homeassistant/components/teslemetry/services.py index 7a6a7b55c0ccb6..4fecd98cf24659 100644 --- a/homeassistant/components/teslemetry/services.py +++ b/homeassistant/components/teslemetry/services.py @@ -152,7 +152,7 @@ async def set_scheduled_charging(call: ServiceCall) -> None: time: int | None = None # Convert time to minutes since minute if "time" in call.data: - (hours, minutes, *seconds) = call.data["time"].split(":") + (hours, minutes, *_seconds) = call.data["time"].split(":") time = int(hours) * 60 + int(minutes) elif call.data["enable"]: raise ServiceValidationError( @@ -191,7 +191,7 @@ async def set_scheduled_departure(call: ServiceCall) -> None: ) departure_time: int | None = None if ATTR_DEPARTURE_TIME in call.data: - (hours, minutes, *seconds) = call.data[ATTR_DEPARTURE_TIME].split(":") + (hours, minutes, *_seconds) = call.data[ATTR_DEPARTURE_TIME].split(":") departure_time = int(hours) * 60 + int(minutes) elif preconditioning_enabled: raise ServiceValidationError( @@ -207,7 +207,7 @@ async def set_scheduled_departure(call: ServiceCall) -> None: end_off_peak_time: int | None = None if ATTR_END_OFF_PEAK_TIME in call.data: - (hours, minutes, *seconds) = call.data[ATTR_END_OFF_PEAK_TIME].split(":") + (hours, minutes, *_seconds) = call.data[ATTR_END_OFF_PEAK_TIME].split(":") end_off_peak_time = int(hours) * 60 + int(minutes) elif off_peak_charging_enabled: raise ServiceValidationError( diff --git a/homeassistant/components/unifiprotect/repairs.py b/homeassistant/components/unifiprotect/repairs.py index 8f24d9046aea25..a043a66e350665 100644 --- a/homeassistant/components/unifiprotect/repairs.py +++ b/homeassistant/components/unifiprotect/repairs.py @@ -5,7 +5,7 @@ from typing import cast from uiprotect import ProtectApiClient -from uiprotect.data import Bootstrap, Camera, ModelType +from uiprotect.data import Bootstrap, Camera import voluptuous as vol from homeassistant import data_entry_flow @@ -114,16 +114,7 @@ async def _get_camera(self) -> Camera: async def _enable_rtsp(self) -> None: camera = await self._get_camera() - bootstrap = await self._get_boostrap() - user = bootstrap.users.get(bootstrap.auth_user_id) - if not user or not camera.can_write(user): - return - - channel = camera.channels[0] - channel.is_rtsp_enabled = True - await self._api.update_device( - ModelType.CAMERA, camera.id, {"channels": camera.unifi_dict()["channels"]} - ) + await camera.create_rtsps_streams(qualities="high") async def async_step_init( self, user_input: dict[str, str] | None = None diff --git a/homeassistant/components/uptimerobot/config_flow.py b/homeassistant/components/uptimerobot/config_flow.py index 5fc165c0f27448..ccbf6c396552f7 100644 --- a/homeassistant/components/uptimerobot/config_flow.py +++ b/homeassistant/components/uptimerobot/config_flow.py @@ -116,3 +116,30 @@ async def async_step_reauth_confirm( return self.async_show_form( step_id="reauth_confirm", data_schema=STEP_USER_DATA_SCHEMA, errors=errors ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle reconfiguration of the device.""" + reconfigure_entry = self._get_reconfigure_entry() + if not user_input: + return self.async_show_form( + step_id="reconfigure", + data_schema=STEP_USER_DATA_SCHEMA, + ) + + self._async_abort_entries_match( + {CONF_API_KEY: reconfigure_entry.data[CONF_API_KEY]} + ) + + errors, account = await self._validate_input(user_input) + if account: + await self.async_set_unique_id(str(account.user_id)) + self._abort_if_unique_id_configured() + return self.async_update_reload_and_abort( + reconfigure_entry, data_updates=user_input + ) + + return self.async_show_form( + step_id="reconfigure", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + ) diff --git a/homeassistant/components/uptimerobot/quality_scale.yaml b/homeassistant/components/uptimerobot/quality_scale.yaml index 2152f572853719..01da4dc5166cb8 100644 --- a/homeassistant/components/uptimerobot/quality_scale.yaml +++ b/homeassistant/components/uptimerobot/quality_scale.yaml @@ -68,9 +68,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: - status: todo - comment: handle API key change/update + reconfiguration-flow: done repair-issues: status: exempt comment: no known use cases for repair issues or flows, yet diff --git a/homeassistant/components/uptimerobot/strings.json b/homeassistant/components/uptimerobot/strings.json index ffee6769c69fdd..f912b6dd993412 100644 --- a/homeassistant/components/uptimerobot/strings.json +++ b/homeassistant/components/uptimerobot/strings.json @@ -17,6 +17,14 @@ "data_description": { "api_key": "[%key:component::uptimerobot::config::step::user::data_description::api_key%]" } + }, + "reconfigure": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::uptimerobot::config::step::user::data_description::api_key%]" + } } }, "error": { @@ -30,6 +38,7 @@ "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", "reauth_failed_existing": "Could not update the config entry, please remove the integration and set it up again.", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]", "unknown": "[%key:common::config_flow::error::unknown%]" } }, diff --git a/homeassistant/components/vodafone_station/coordinator.py b/homeassistant/components/vodafone_station/coordinator.py index 35c32ab2af3035..5a3330b16c6211 100644 --- a/homeassistant/components/vodafone_station/coordinator.py +++ b/homeassistant/components/vodafone_station/coordinator.py @@ -8,11 +8,14 @@ from aiohttp import ClientSession from aiovodafone import VodafoneStationDevice, VodafoneStationSercommApi, exceptions -from homeassistant.components.device_tracker import DEFAULT_CONSIDER_HOME +from homeassistant.components.device_tracker import ( + DEFAULT_CONSIDER_HOME, + DOMAIN as DEVICE_TRACKER_DOMAIN, +) from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryAuthFailed -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import entity_registry as er from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util @@ -71,16 +74,14 @@ def __init__( update_interval=timedelta(seconds=SCAN_INTERVAL), config_entry=config_entry, ) - device_reg = dr.async_get(self.hass) - device_list = dr.async_entries_for_config_entry( - device_reg, self.config_entry.entry_id - ) + entity_reg = er.async_get(hass) self.previous_devices = { - connection[1].upper() - for device in device_list - for connection in device.connections - if connection[0] == dr.CONNECTION_NETWORK_MAC + entry.unique_id + for entry in er.async_entries_for_config_entry( + entity_reg, config_entry.entry_id + ) + if entry.domain == DEVICE_TRACKER_DOMAIN } def _calculate_update_time_and_consider_home( diff --git a/homeassistant/components/whirlpool/binary_sensor.py b/homeassistant/components/whirlpool/binary_sensor.py index d26f5764313c6b..82f34882b919ae 100644 --- a/homeassistant/components/whirlpool/binary_sensor.py +++ b/homeassistant/components/whirlpool/binary_sensor.py @@ -17,6 +17,7 @@ from . import WhirlpoolConfigEntry from .entity import WhirlpoolEntity +PARALLEL_UPDATES = 1 SCAN_INTERVAL = timedelta(minutes=5) diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index c91ffb12714c7d..af406f359fdefc 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -25,6 +25,8 @@ from . import WhirlpoolConfigEntry from .entity import WhirlpoolEntity +PARALLEL_UPDATES = 1 + AIRCON_MODE_MAP = { AirconMode.Cool: HVACMode.COOL, AirconMode.Heat: HVACMode.HEAT, @@ -92,7 +94,9 @@ def target_temperature(self) -> float: async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" - await self._appliance.set_temp(kwargs.get(ATTR_TEMPERATURE)) + AirConEntity._check_service_request( + await self._appliance.set_temp(kwargs.get(ATTR_TEMPERATURE)) + ) @property def current_humidity(self) -> int: @@ -106,7 +110,9 @@ def target_humidity(self) -> int: async def async_set_humidity(self, humidity: int) -> None: """Set new target humidity.""" - await self._appliance.set_humidity(humidity) + AirConEntity._check_service_request( + await self._appliance.set_humidity(humidity) + ) @property def hvac_mode(self) -> HVACMode | None: @@ -120,13 +126,17 @@ def hvac_mode(self) -> HVACMode | None: async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None: """Set HVAC mode.""" if hvac_mode == HVACMode.OFF: - await self._appliance.set_power_on(False) + AirConEntity._check_service_request( + await self._appliance.set_power_on(False) + ) return mode = HVAC_MODE_TO_AIRCON_MODE[hvac_mode] - await self._appliance.set_mode(mode) + AirConEntity._check_service_request(await self._appliance.set_mode(mode)) if not self._appliance.get_power_on(): - await self._appliance.set_power_on(True) + AirConEntity._check_service_request( + await self._appliance.set_power_on(True) + ) @property def fan_mode(self) -> str: @@ -137,7 +147,9 @@ def fan_mode(self) -> str: async def async_set_fan_mode(self, fan_mode: str) -> None: """Set fan mode.""" fanspeed = FAN_MODE_TO_AIRCON_FANSPEED[fan_mode] - await self._appliance.set_fanspeed(fanspeed) + AirConEntity._check_service_request( + await self._appliance.set_fanspeed(fanspeed) + ) @property def swing_mode(self) -> str: @@ -145,13 +157,15 @@ def swing_mode(self) -> str: return SWING_HORIZONTAL if self._appliance.get_h_louver_swing() else SWING_OFF async def async_set_swing_mode(self, swing_mode: str) -> None: - """Set new target temperature.""" - await self._appliance.set_h_louver_swing(swing_mode == SWING_HORIZONTAL) + """Set swing mode.""" + AirConEntity._check_service_request( + await self._appliance.set_h_louver_swing(swing_mode == SWING_HORIZONTAL) + ) async def async_turn_on(self) -> None: """Turn device on.""" - await self._appliance.set_power_on(True) + AirConEntity._check_service_request(await self._appliance.set_power_on(True)) async def async_turn_off(self) -> None: """Turn device off.""" - await self._appliance.set_power_on(False) + AirConEntity._check_service_request(await self._appliance.set_power_on(False)) diff --git a/homeassistant/components/whirlpool/entity.py b/homeassistant/components/whirlpool/entity.py index a53fe0af26364a..ee2f25cd3c88d3 100644 --- a/homeassistant/components/whirlpool/entity.py +++ b/homeassistant/components/whirlpool/entity.py @@ -2,6 +2,7 @@ from whirlpool.appliance import Appliance +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import Entity @@ -38,3 +39,12 @@ async def async_will_remove_from_hass(self) -> None: def available(self) -> bool: """Return True if entity is available.""" return self._appliance.get_online() + + @staticmethod + def _check_service_request(result: bool) -> None: + """Check result of a request and raise HomeAssistantError if it failed.""" + if not result: + raise HomeAssistantError( + translation_domain=DOMAIN, + translation_key="request_failed", + ) diff --git a/homeassistant/components/whirlpool/sensor.py b/homeassistant/components/whirlpool/sensor.py index 1bb825cc18f869..545ae67eaa13f7 100644 --- a/homeassistant/components/whirlpool/sensor.py +++ b/homeassistant/components/whirlpool/sensor.py @@ -24,6 +24,7 @@ from . import WhirlpoolConfigEntry from .entity import WhirlpoolEntity +PARALLEL_UPDATES = 1 SCAN_INTERVAL = timedelta(minutes=5) WASHER_TANK_FILL = { diff --git a/homeassistant/components/whirlpool/strings.json b/homeassistant/components/whirlpool/strings.json index 9f214bf204f474..3ab65d2e3aa934 100644 --- a/homeassistant/components/whirlpool/strings.json +++ b/homeassistant/components/whirlpool/strings.json @@ -129,6 +129,9 @@ }, "appliances_fetch_failed": { "message": "Failed to fetch appliances" + }, + "request_failed": { + "message": "Request failed" } } } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index d636fce1d3cbfa..99cbbbde73a9f9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -149,6 +149,7 @@ "downloader", "dremel_3d_printer", "drop_connect", + "droplet", "dsmr", "dsmr_reader", "duke_energy", @@ -494,6 +495,7 @@ "point", "pooldose", "poolsense", + "portainer", "powerfox", "powerwall", "private_ble_device", diff --git a/homeassistant/generated/dhcp.py b/homeassistant/generated/dhcp.py index 3c1d929b1d8584..ab95b106551bf2 100644 --- a/homeassistant/generated/dhcp.py +++ b/homeassistant/generated/dhcp.py @@ -26,6 +26,10 @@ "domain": "airzone", "macaddress": "E84F25*", }, + { + "domain": "aladdin_connect", + "hostname": "gdocntl-*", + }, { "domain": "august", "hostname": "connect", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 183c7956275310..4320d274c7d0c0 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -22,8 +22,7 @@ "name": "AccuWeather", "integration_type": "service", "config_flow": true, - "iot_class": "cloud_polling", - "single_config_entry": true + "iot_class": "cloud_polling" }, "acer_projector": { "name": "Acer Projector", @@ -1441,6 +1440,12 @@ "config_flow": true, "iot_class": "local_push" }, + "droplet": { + "name": "Droplet", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "dsmr": { "name": "DSMR Smart Meter", "integration_type": "hub", @@ -5066,6 +5071,12 @@ "config_flow": true, "iot_class": "cloud_polling" }, + "portainer": { + "name": "Portainer", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_polling" + }, "portlandgeneral": { "name": "Portland General Electric (PGE)", "integration_type": "virtual", diff --git a/homeassistant/generated/zeroconf.py b/homeassistant/generated/zeroconf.py index 742840fa84957e..2162af50158695 100644 --- a/homeassistant/generated/zeroconf.py +++ b/homeassistant/generated/zeroconf.py @@ -464,6 +464,11 @@ "domain": "daikin", }, ], + "_droplet._tcp.local.": [ + { + "domain": "droplet", + }, + ], "_dvl-deviceapi._tcp.local.": [ { "domain": "devolo_home_control", diff --git a/mypy.ini b/mypy.ini index 5787bb8de8405d..5b1f9d3eb0a924 100644 --- a/mypy.ini +++ b/mypy.ini @@ -1446,6 +1446,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.droplet.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.dsmr.*] check_untyped_defs = true disallow_incomplete_defs = true @@ -3766,6 +3776,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.portainer.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.powerfox.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/pyproject.toml b/pyproject.toml index 955068cb6a4778..eefeb59d5a3c94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -468,7 +468,7 @@ filterwarnings = [ "ignore:Unknown pytest.mark.dataset:pytest.PytestUnknownMarkWarning:tests.components.screenlogic", # -- DeprecationWarning already fixed in our codebase - # https://github.com/kurtmckee/feedparser/pull/389 - 6.0.11 + # https://github.com/kurtmckee/feedparser/ - 6.0.12 "ignore:.*a temporary mapping .* from `updated_parsed` to `published_parsed` if `updated_parsed` doesn't exist:DeprecationWarning:feedparser.util", # -- design choice 3rd party @@ -569,9 +569,6 @@ filterwarnings = [ "ignore:\"is.*\" with '.*' literal:SyntaxWarning:importlib._bootstrap", # -- New in Python 3.13 - # https://github.com/kurtmckee/feedparser/pull/389 - >6.0.11 - # https://github.com/kurtmckee/feedparser/issues/481 - "ignore:'count' is passed as positional argument:DeprecationWarning:feedparser.html", # https://github.com/youknowone/python-deadlib - Backports for aifc, telnetlib "ignore:aifc was removed in Python 3.13.*'standard-aifc':DeprecationWarning:speech_recognition", "ignore:telnetlib was removed in Python 3.13.*'standard-telnetlib':DeprecationWarning:homeassistant.components.hddtemp.sensor", @@ -640,7 +637,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.12.1" +required-version = ">=0.13.0" [tool.ruff.lint] select = [ @@ -710,6 +707,7 @@ select = [ "RUF032", # Decimal() called with float literal argument "RUF033", # __post_init__ method with argument defaults "RUF034", # Useless if-else condition + "RUF059", # unused-unpacked-variable "RUF100", # Unused `noqa` directive "RUF101", # noqa directives that use redirected rule codes "RUF200", # Failed to parse pyproject.toml: {message} @@ -782,8 +780,7 @@ ignore = [ "TRY003", # Avoid specifying long messages outside the exception class "TRY400", # Use `logging.exception` instead of `logging.error` - # Ignored due to performance: https://github.com/charliermarsh/ruff/issues/2923 - "UP038", # Use `X | Y` in `isinstance` call instead of `(X, Y)` + "UP046", # Non PEP 695 generic class "UP047", # Non PEP 696 generic function "UP049", # Avoid private type parameter names @@ -801,6 +798,8 @@ ignore = [ # Disabled because ruff does not understand type of __all__ generated by a function "PLE0605", + + "FURB116" ] [tool.ruff.lint.flake8-import-conventions.extend-aliases] diff --git a/requirements_all.txt b/requirements_all.txt index 095220e8f6ab52..19954a75795b0a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -84,7 +84,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.69.0 +PySwitchbot==0.70.0 # homeassistant.components.switchmate PySwitchmate==0.5.1 @@ -936,7 +936,7 @@ faadelays==2023.9.1 fastdotcom==0.0.3 # homeassistant.components.feedreader -feedparser==6.0.11 +feedparser==6.0.12 # homeassistant.components.file file-read-backwards==2.0.0 @@ -1945,6 +1945,9 @@ pydrawise==2025.9.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 +# homeassistant.components.droplet +pydroplet==2.3.2 + # homeassistant.components.ebox pyebox==1.1.4 @@ -2263,6 +2266,9 @@ pyplaato==0.0.19 # homeassistant.components.point pypoint==3.0.0 +# homeassistant.components.portainer +pyportainer==0.1.7 + # homeassistant.components.probe_plus pyprobeplus==1.0.1 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1d1fc01b5b6667..79986cbe3e2403 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -81,7 +81,7 @@ PyQRCode==1.2.1 PyRMVtransport==0.3.3 # homeassistant.components.switchbot -PySwitchbot==0.69.0 +PySwitchbot==0.70.0 # homeassistant.components.syncthru PySyncThru==0.8.0 @@ -815,7 +815,7 @@ faadelays==2023.9.1 fastdotcom==0.0.3 # homeassistant.components.feedreader -feedparser==6.0.11 +feedparser==6.0.12 # homeassistant.components.file file-read-backwards==2.0.0 @@ -1626,6 +1626,9 @@ pydrawise==2025.9.0 # homeassistant.components.android_ip_webcam pydroid-ipcam==3.0.0 +# homeassistant.components.droplet +pydroplet==2.3.2 + # homeassistant.components.ecoforest pyecoforest==0.4.0 @@ -1887,6 +1890,9 @@ pyplaato==0.0.19 # homeassistant.components.point pypoint==3.0.0 +# homeassistant.components.portainer +pyportainer==0.1.7 + # homeassistant.components.probe_plus pyprobeplus==1.0.1 diff --git a/requirements_test_pre_commit.txt b/requirements_test_pre_commit.txt index b9c800be3ca834..44689bd12fe6b3 100644 --- a/requirements_test_pre_commit.txt +++ b/requirements_test_pre_commit.txt @@ -1,5 +1,5 @@ # Automatically generated from .pre-commit-config.yaml by gen_requirements_all.py, do not edit codespell==2.4.1 -ruff==0.12.1 +ruff==0.13.0 yamllint==1.37.1 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 24e0fd24501567..18550535fbe727 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -27,7 +27,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.8.9,source=/uv,target=/bin/uv \ stdlib-list==0.10.0 \ pipdeptree==2.26.1 \ tqdm==4.67.1 \ - ruff==0.12.1 \ + ruff==0.13.0 \ PyTurboJPEG==1.8.0 \ go2rtc-client==0.2.1 \ ha-ffmpeg==3.2.2 \ diff --git a/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 62074b9a1cf108..2c34cf36c88cf2 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -486,7 +486,6 @@ class Rule: "hp_ilo", "html5", "http", - "huawei_lte", "hue", "huisbaasje", "hunterdouglas_powerview", @@ -530,7 +529,6 @@ class Rule: "itunes", "izone", "jellyfin", - "jewish_calendar", "joaoapps_join", "juicenet", "justnimbus", diff --git a/tests/components/accuweather/test_config_flow.py b/tests/components/accuweather/test_config_flow.py index 63ad8bf5513001..ff1f31f01bc8be 100644 --- a/tests/components/accuweather/test_config_flow.py +++ b/tests/components/accuweather/test_config_flow.py @@ -87,7 +87,7 @@ async def test_integration_already_exists( """Test we only allow a single config flow.""" MockConfigEntry( domain=DOMAIN, - unique_id="123456", + unique_id="0123456", data=VALID_CONFIG, ).add_to_hass(hass) @@ -98,7 +98,7 @@ async def test_integration_already_exists( ) assert result["type"] is FlowResultType.ABORT - assert result["reason"] == "single_instance_allowed" + assert result["reason"] == "already_configured" async def test_create_entry( diff --git a/tests/components/aladdin_connect/test_config_flow.py b/tests/components/aladdin_connect/test_config_flow.py index c0aafe933701a8..d69c588a649099 100644 --- a/tests/components/aladdin_connect/test_config_flow.py +++ b/tests/components/aladdin_connect/test_config_flow.py @@ -10,9 +10,11 @@ OAUTH2_AUTHORIZE, OAUTH2_TOKEN, ) +from homeassistant.config_entries import SOURCE_DHCP from homeassistant.core import HomeAssistant from homeassistant.data_entry_flow import FlowResultType from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.helpers.service_info.dhcp import DhcpServiceInfo from .const import CLIENT_ID, USER_ID @@ -95,6 +97,79 @@ async def test_full_flow( assert result["result"].unique_id == USER_ID +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_dhcp_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", macaddress="001122334455", hostname="gdocntl-334455" + ), + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "oauth_discovery" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {}, + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": access_token, + "type": "Bearer", + "expires_in": 60, + }, + ) + + with patch( + "homeassistant.components.aladdin_connect.async_setup_entry", return_value=True + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "Aladdin Connect" + assert result["data"] == { + "auth_implementation": DOMAIN, + "token": { + "access_token": access_token, + "refresh_token": "mock-refresh-token", + "expires_in": 60, + "expires_at": result["data"]["token"]["expires_at"], + "type": "Bearer", + }, + } + assert result["result"].unique_id == USER_ID + + @pytest.mark.usefixtures("current_request_with_host") async def test_duplicate_entry( hass: HomeAssistant, @@ -146,6 +221,28 @@ async def test_duplicate_entry( assert result["reason"] == "already_configured" +@pytest.mark.usefixtures("current_request_with_host") +async def test_duplicate_dhcp_entry( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + access_token, + mock_config_entry: MockConfigEntry, +) -> None: + """Check full flow.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_DHCP}, + data=DhcpServiceInfo( + ip="192.168.0.123", macaddress="001122334455", hostname="gdocntl-334455" + ), + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + @pytest.mark.usefixtures("current_request_with_host") async def test_flow_reauth( hass: HomeAssistant, diff --git a/tests/components/alexa/test_capabilities.py b/tests/components/alexa/test_capabilities.py index b10a93df0c935e..d83956ee128bb1 100644 --- a/tests/components/alexa/test_capabilities.py +++ b/tests/components/alexa/test_capabilities.py @@ -383,7 +383,7 @@ async def test_api_remote_set_power_state( }, ) - _, msg = await assert_request_calls_service( + _, _msg = await assert_request_calls_service( "Alexa.PowerController", target_name, "remote#test", diff --git a/tests/components/alexa/test_smart_home.py b/tests/components/alexa/test_smart_home.py index e4a46db7d3449d..5c9555b6581c57 100644 --- a/tests/components/alexa/test_smart_home.py +++ b/tests/components/alexa/test_smart_home.py @@ -1815,7 +1815,7 @@ async def test_media_player_seek_error(hass: HomeAssistant) -> None: # Test for media_position error. with pytest.raises(AssertionError): - _, msg = await assert_request_calls_service( + _, _msg = await assert_request_calls_service( "Alexa.SeekController", "AdjustSeekPosition", "media_player#test_seek", @@ -2374,7 +2374,7 @@ async def test_cover_position_range( "range": {"minimumValue": 1, "maximumValue": 100}, } in position_state_mappings - call, msg = await assert_request_calls_service( + _call, msg = await assert_request_calls_service( "Alexa.RangeController", "AdjustRangeValue", "cover#test_range", diff --git a/tests/components/alexa_devices/conftest.py b/tests/components/alexa_devices/conftest.py index 2ef2c2431dc136..bf35d87cb90a39 100644 --- a/tests/components/alexa_devices/conftest.py +++ b/tests/components/alexa_devices/conftest.py @@ -1,9 +1,9 @@ """Alexa Devices tests configuration.""" from collections.abc import Generator +from copy import deepcopy from unittest.mock import AsyncMock, patch -from aioamazondevices.api import AmazonDevice, AmazonDeviceSensor from aioamazondevices.const import DEVICE_TYPE_TO_MODEL import pytest @@ -14,7 +14,7 @@ ) from homeassistant.const import CONF_PASSWORD, CONF_USERNAME -from .const import TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME +from .const import TEST_DEVICE, TEST_PASSWORD, TEST_SERIAL_NUMBER, TEST_USERNAME from tests.common import MockConfigEntry @@ -47,27 +47,7 @@ def mock_amazon_devices_client() -> Generator[AsyncMock]: "customer_info": {"user_id": TEST_USERNAME}, } client.get_devices_data.return_value = { - TEST_SERIAL_NUMBER: AmazonDevice( - account_name="Echo Test", - capabilities=["AUDIO_PLAYER", "MICROPHONE"], - device_family="mine", - device_type="echo", - device_owner_customer_id="amazon_ower_id", - device_cluster_members=[TEST_SERIAL_NUMBER], - online=True, - serial_number=TEST_SERIAL_NUMBER, - software_version="echo_test_software_version", - do_not_disturb=False, - response_style=None, - bluetooth_state=True, - entity_id="11111111-2222-3333-4444-555555555555", - appliance_id="G1234567890123456789012345678A", - sensors={ - "temperature": AmazonDeviceSensor( - name="temperature", value="22.5", scale="CELSIUS" - ) - }, - ) + TEST_SERIAL_NUMBER: deepcopy(TEST_DEVICE) } client.get_model_details = lambda device: DEVICE_TYPE_TO_MODEL.get( device.device_type diff --git a/tests/components/alexa_devices/const.py b/tests/components/alexa_devices/const.py index ca701cd46e84df..fa30226849eed6 100644 --- a/tests/components/alexa_devices/const.py +++ b/tests/components/alexa_devices/const.py @@ -1,8 +1,32 @@ """Alexa Devices tests const.""" +from aioamazondevices.api import AmazonDevice, AmazonDeviceSensor + TEST_CODE = "023123" TEST_PASSWORD = "fake_password" TEST_SERIAL_NUMBER = "echo_test_serial_number" TEST_USERNAME = "fake_email@gmail.com" TEST_DEVICE_ID = "echo_test_device_id" + +TEST_DEVICE = AmazonDevice( + account_name="Echo Test", + capabilities=["AUDIO_PLAYER", "MICROPHONE"], + device_family="mine", + device_type="echo", + device_owner_customer_id="amazon_ower_id", + device_cluster_members=[TEST_SERIAL_NUMBER], + online=True, + serial_number=TEST_SERIAL_NUMBER, + software_version="echo_test_software_version", + do_not_disturb=False, + response_style=None, + bluetooth_state=True, + entity_id="11111111-2222-3333-4444-555555555555", + appliance_id="G1234567890123456789012345678A", + sensors={ + "temperature": AmazonDeviceSensor( + name="temperature", value="22.5", scale="CELSIUS" + ) + }, +) diff --git a/tests/components/alexa_devices/test_coordinator.py b/tests/components/alexa_devices/test_coordinator.py new file mode 100644 index 00000000000000..3768404f871794 --- /dev/null +++ b/tests/components/alexa_devices/test_coordinator.py @@ -0,0 +1,73 @@ +"""Tests for the Alexa Devices coordinator.""" + +from unittest.mock import AsyncMock + +from aioamazondevices.api import AmazonDevice, AmazonDeviceSensor +from freezegun.api import FrozenDateTimeFactory + +from homeassistant.components.alexa_devices.coordinator import SCAN_INTERVAL +from homeassistant.const import STATE_ON +from homeassistant.core import HomeAssistant + +from . import setup_integration +from .const import TEST_DEVICE, TEST_SERIAL_NUMBER + +from tests.common import MockConfigEntry, async_fire_time_changed + + +async def test_coordinator_stale_device( + hass: HomeAssistant, + freezer: FrozenDateTimeFactory, + mock_amazon_devices_client: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test coordinator data update removes stale Alexa devices.""" + + entity_id_0 = "binary_sensor.echo_test_connectivity" + entity_id_1 = "binary_sensor.echo_test_2_connectivity" + + mock_amazon_devices_client.get_devices_data.return_value = { + TEST_SERIAL_NUMBER: TEST_DEVICE, + "echo_test_2_serial_number_2": AmazonDevice( + account_name="Echo Test 2", + capabilities=["AUDIO_PLAYER", "MICROPHONE"], + device_family="mine", + device_type="echo", + device_owner_customer_id="amazon_ower_id", + device_cluster_members=["echo_test_2_serial_number_2"], + online=True, + serial_number="echo_test_2_serial_number_2", + software_version="echo_test_2_software_version", + do_not_disturb=False, + response_style=None, + bluetooth_state=True, + entity_id="11111111-2222-3333-4444-555555555555", + appliance_id="G1234567890123456789012345678A", + sensors={ + "temperature": AmazonDeviceSensor( + name="temperature", value="22.5", scale="CELSIUS" + ) + }, + ), + } + + await setup_integration(hass, mock_config_entry) + + assert (state := hass.states.get(entity_id_0)) + assert state.state == STATE_ON + assert (state := hass.states.get(entity_id_1)) + assert state.state == STATE_ON + + mock_amazon_devices_client.get_devices_data.return_value = { + TEST_SERIAL_NUMBER: TEST_DEVICE, + } + + freezer.tick(SCAN_INTERVAL) + async_fire_time_changed(hass) + await hass.async_block_till_done() + + assert (state := hass.states.get(entity_id_0)) + assert state.state == STATE_ON + + # Entity is removed + assert not hass.states.get(entity_id_1) diff --git a/tests/components/derivative/test_sensor.py b/tests/components/derivative/test_sensor.py index 211e6f673ca7c2..5a601ad26dd65f 100644 --- a/tests/components/derivative/test_sensor.py +++ b/tests/components/derivative/test_sensor.py @@ -717,7 +717,7 @@ async def test_total_increasing_reset(hass: HomeAssistant) -> None: expected_times = [0, 20, 30, 35, 50, 60] expected_values = ["0.00", "0.50", "2.00", "2.00", "1.00", "3.00"] - config, entity_id = await _setup_sensor(hass, {"unit_time": UnitOfTime.SECONDS}) + _config, entity_id = await _setup_sensor(hass, {"unit_time": UnitOfTime.SECONDS}) base_time = dt_util.utcnow() actual_times = [] diff --git a/tests/components/droplet/__init__.py b/tests/components/droplet/__init__.py new file mode 100644 index 00000000000000..633b89a974917b --- /dev/null +++ b/tests/components/droplet/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Droplet integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/droplet/conftest.py b/tests/components/droplet/conftest.py new file mode 100644 index 00000000000000..8b3792a95fe578 --- /dev/null +++ b/tests/components/droplet/conftest.py @@ -0,0 +1,108 @@ +"""Common fixtures for the Droplet tests.""" + +from collections.abc import Generator +from datetime import datetime +from unittest.mock import AsyncMock, patch + +import pytest + +from homeassistant.components.droplet.const import DOMAIN +from homeassistant.const import CONF_CODE, CONF_IP_ADDRESS, CONF_PORT + +from tests.common import MockConfigEntry + +MOCK_CODE = "11223" +MOCK_HOST = "192.168.1.2" +MOCK_PORT = 443 +MOCK_DEVICE_ID = "Droplet-1234" +MOCK_MANUFACTURER = "Hydrific, part of LIXIL" +MOCK_SN = "1234" +MOCK_SW_VERSION = "v1.0.0" +MOCK_MODEL = "Droplet 1.0" + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={CONF_IP_ADDRESS: MOCK_HOST, CONF_PORT: MOCK_PORT, CONF_CODE: MOCK_CODE}, + unique_id=MOCK_DEVICE_ID, + ) + + +@pytest.fixture +def mock_droplet() -> Generator[AsyncMock]: + """Mock a Droplet client.""" + with ( + patch( + "homeassistant.components.droplet.coordinator.Droplet", + autospec=True, + ) as mock_client, + ): + client = mock_client.return_value + client.get_signal_quality.return_value = "strong_signal" + client.get_server_status.return_value = "connected" + client.get_flow_rate.return_value = 0.1 + client.get_manufacturer.return_value = MOCK_MANUFACTURER + client.get_model.return_value = MOCK_MODEL + client.get_fw_version.return_value = MOCK_SW_VERSION + client.get_sn.return_value = MOCK_SN + client.get_volume_last_fetched.return_value = datetime( + year=2020, month=1, day=1 + ) + yield client + + +@pytest.fixture(autouse=True) +def mock_timeout() -> Generator[None]: + """Mock the timeout.""" + with ( + patch( + "homeassistant.components.droplet.coordinator.TIMEOUT", + 0.05, + ), + patch( + "homeassistant.components.droplet.coordinator.VERSION_TIMEOUT", + 0.1, + ), + patch( + "homeassistant.components.droplet.coordinator.CONNECT_DELAY", + 0.1, + ), + ): + yield + + +@pytest.fixture +def mock_droplet_connection() -> Generator[AsyncMock]: + """Mock a Droplet connection.""" + with ( + patch( + "homeassistant.components.droplet.config_flow.DropletConnection", + autospec=True, + ) as mock_client, + ): + client = mock_client.return_value + yield client + + +@pytest.fixture +def mock_droplet_discovery(request: pytest.FixtureRequest) -> Generator[AsyncMock]: + """Mock a DropletDiscovery.""" + with ( + patch( + "homeassistant.components.droplet.config_flow.DropletDiscovery", + autospec=True, + ) as mock_client, + ): + client = mock_client.return_value + # Not all tests set this value + try: + client.host = request.param + except AttributeError: + client.host = MOCK_HOST + client.port = MOCK_PORT + client.try_connect.return_value = True + client.get_device_id.return_value = MOCK_DEVICE_ID + yield client diff --git a/tests/components/droplet/snapshots/test_sensor.ambr b/tests/components/droplet/snapshots/test_sensor.ambr new file mode 100644 index 00000000000000..aa92d6df0afe4d --- /dev/null +++ b/tests/components/droplet/snapshots/test_sensor.ambr @@ -0,0 +1,240 @@ +# serializer version: 1 +# name: test_sensors[sensor.mock_title_flow_rate-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_flow_rate', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Flow rate', + 'platform': 'droplet', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_flow_rate', + 'unique_id': 'Droplet-1234_current_flow_rate', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.mock_title_flow_rate-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'volume_flow_rate', + 'friendly_name': 'Mock Title Flow rate', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_flow_rate', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0264172052358148', + }) +# --- +# name: test_sensors[sensor.mock_title_server_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'connected', + 'connecting', + 'disconnected', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_server_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Server status', + 'platform': 'droplet', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'server_connectivity', + 'unique_id': 'Droplet-1234_server_connectivity', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.mock_title_server_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Title Server status', + 'options': list([ + 'connected', + 'connecting', + 'disconnected', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_server_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'connected', + }) +# --- +# name: test_sensors[sensor.mock_title_signal_quality-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'options': list([ + 'no_signal', + 'weak_signal', + 'strong_signal', + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': , + 'entity_id': 'sensor.mock_title_signal_quality', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Signal quality', + 'platform': 'droplet', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'signal_quality', + 'unique_id': 'Droplet-1234_signal_quality', + 'unit_of_measurement': None, + }) +# --- +# name: test_sensors[sensor.mock_title_signal_quality-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'enum', + 'friendly_name': 'Mock Title Signal quality', + 'options': list([ + 'no_signal', + 'weak_signal', + 'strong_signal', + ]), + }), + 'context': , + 'entity_id': 'sensor.mock_title_signal_quality', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'strong_signal', + }) +# --- +# name: test_sensors[sensor.mock_title_water-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.mock_title_water', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + 'sensor.private': dict({ + 'suggested_unit_of_measurement': , + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Water', + 'platform': 'droplet', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': 'Droplet-1234_volume', + 'unit_of_measurement': , + }) +# --- +# name: test_sensors[sensor.mock_title_water-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'water', + 'friendly_name': 'Mock Title Water', + 'last_reset': '2020-01-01T00:00:00', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.mock_title_water', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.264172052358148', + }) +# --- diff --git a/tests/components/droplet/test_config_flow.py b/tests/components/droplet/test_config_flow.py new file mode 100644 index 00000000000000..88a66664c8fc76 --- /dev/null +++ b/tests/components/droplet/test_config_flow.py @@ -0,0 +1,271 @@ +"""Test Droplet config flow.""" + +from ipaddress import IPv4Address +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.components.droplet.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER, SOURCE_ZEROCONF +from homeassistant.const import ( + ATTR_CODE, + CONF_CODE, + CONF_DEVICE_ID, + CONF_IP_ADDRESS, + CONF_PORT, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers.service_info.zeroconf import ZeroconfServiceInfo + +from .conftest import MOCK_CODE, MOCK_DEVICE_ID, MOCK_HOST, MOCK_PORT + +from tests.common import MockConfigEntry + + +async def test_user_setup( + hass: HomeAssistant, + mock_droplet_discovery: AsyncMock, + mock_droplet_connection: AsyncMock, + mock_droplet: AsyncMock, +) -> None: + """Test successful Droplet user setup.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result is not None + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE, CONF_IP_ADDRESS: "192.168.1.2"}, + ) + assert result is not None + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("data") == { + CONF_CODE: MOCK_CODE, + CONF_DEVICE_ID: MOCK_DEVICE_ID, + CONF_IP_ADDRESS: MOCK_HOST, + CONF_PORT: MOCK_PORT, + } + assert result.get("context") is not None + assert result.get("context", {}).get("unique_id") == MOCK_DEVICE_ID + + +@pytest.mark.parametrize( + ("device_id", "connect_res"), + [ + ( + "", + True, + ), + (MOCK_DEVICE_ID, False), + ], + ids=["no_device_id", "cannot_connect"], +) +async def test_user_setup_fail( + hass: HomeAssistant, + device_id: str, + connect_res: bool, + mock_droplet_discovery: AsyncMock, + mock_droplet_connection: AsyncMock, + mock_droplet: AsyncMock, +) -> None: + """Test user setup failing due to no device ID or failed connection.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result is not None + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + attrs = { + "get_device_id.return_value": device_id, + "try_connect.return_value": connect_res, + } + mock_droplet_discovery.configure_mock(**attrs) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE, CONF_IP_ADDRESS: MOCK_HOST}, + ) + assert result is not None + assert result.get("type") is FlowResultType.FORM + assert result.get("errors") == {"base": "cannot_connect"} + + # The user should be able to try again. Maybe the droplet was disconnected from the network or something + attrs = { + "get_device_id.return_value": MOCK_DEVICE_ID, + "try_connect.return_value": True, + } + mock_droplet_discovery.configure_mock(**attrs) + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE, CONF_IP_ADDRESS: MOCK_HOST}, + ) + assert result is not None + assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_user_setup_already_configured( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_droplet_discovery: AsyncMock, + mock_droplet: AsyncMock, + mock_droplet_connection: AsyncMock, +) -> None: + """Test user setup of an already-configured device.""" + mock_config_entry.add_to_hass(hass) + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result is not None + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_CODE: MOCK_CODE, CONF_IP_ADDRESS: MOCK_HOST}, + ) + assert result is not None + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + +async def test_zeroconf_setup( + hass: HomeAssistant, + mock_droplet_discovery: AsyncMock, + mock_droplet: AsyncMock, + mock_droplet_connection: AsyncMock, +) -> None: + """Test successful setup of Droplet via zeroconf.""" + discovery_info = ZeroconfServiceInfo( + ip_address=IPv4Address(MOCK_HOST), + ip_addresses=[IPv4Address(MOCK_HOST)], + port=MOCK_PORT, + hostname=MOCK_DEVICE_ID, + type="_droplet._tcp.local.", + name=MOCK_DEVICE_ID, + properties={}, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result is not None + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "confirm" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_CODE: MOCK_CODE} + ) + assert result is not None + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("data") == { + CONF_DEVICE_ID: MOCK_DEVICE_ID, + CONF_IP_ADDRESS: MOCK_HOST, + CONF_PORT: MOCK_PORT, + CONF_CODE: MOCK_CODE, + } + assert result.get("context") is not None + assert result.get("context", {}).get("unique_id") == MOCK_DEVICE_ID + + +@pytest.mark.parametrize("mock_droplet_discovery", ["192.168.1.5"], indirect=True) +async def test_zeroconf_update( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_droplet_discovery: AsyncMock, +) -> None: + """Test updating Droplet's host with zeroconf.""" + mock_config_entry.add_to_hass(hass) + + # We start with a different host + new_host = "192.168.1.5" + assert mock_config_entry.data[CONF_IP_ADDRESS] != new_host + + # After this discovery message, host should be updated + discovery_info = ZeroconfServiceInfo( + ip_address=IPv4Address(new_host), + ip_addresses=[IPv4Address(new_host)], + port=MOCK_PORT, + hostname=MOCK_DEVICE_ID, + type="_droplet._tcp.local.", + name=MOCK_DEVICE_ID, + properties={}, + ) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result is not None + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" + + assert mock_config_entry.data[CONF_IP_ADDRESS] == new_host + + +async def test_zeroconf_invalid_discovery(hass: HomeAssistant) -> None: + """Test that invalid discovery information causes the config flow to abort.""" + discovery_info = ZeroconfServiceInfo( + ip_address=IPv4Address(MOCK_HOST), + ip_addresses=[IPv4Address(MOCK_HOST)], + port=-1, + hostname=MOCK_DEVICE_ID, + type="_droplet._tcp.local.", + name=MOCK_DEVICE_ID, + properties={}, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result is not None + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "invalid_discovery_info" + + +async def test_confirm_cannot_connect( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_droplet: AsyncMock, + mock_droplet_connection: AsyncMock, + mock_droplet_discovery: AsyncMock, +) -> None: + """Test that config flow fails when Droplet can't connect.""" + discovery_info = ZeroconfServiceInfo( + ip_address=IPv4Address(MOCK_HOST), + ip_addresses=[IPv4Address(MOCK_HOST)], + port=MOCK_PORT, + hostname=MOCK_DEVICE_ID, + type="_droplet._tcp.local.", + name=MOCK_DEVICE_ID, + properties={}, + ) + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=discovery_info, + ) + assert result.get("type") is FlowResultType.FORM + + # Mock the connection failing + mock_droplet_discovery.try_connect.return_value = False + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {ATTR_CODE: MOCK_CODE} + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("errors")["base"] == "cannot_connect" + + mock_droplet_discovery.try_connect.return_value = True + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={ATTR_CODE: MOCK_CODE} + ) + assert result.get("type") is FlowResultType.CREATE_ENTRY, result diff --git a/tests/components/droplet/test_init.py b/tests/components/droplet/test_init.py new file mode 100644 index 00000000000000..7c4f98c62e73f7 --- /dev/null +++ b/tests/components/droplet/test_init.py @@ -0,0 +1,41 @@ +"""Test Droplet initialization.""" + +from unittest.mock import AsyncMock + +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +async def test_setup_no_version_info( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_droplet_discovery: AsyncMock, + mock_droplet_connection: AsyncMock, + mock_droplet: AsyncMock, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test coordinator setup where Droplet never sends version info.""" + mock_droplet.version_info_available.return_value = False + await setup_integration(hass, mock_config_entry) + + assert "Failed to get version info from Droplet" in caplog.text + + +async def test_setup_droplet_offline( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_droplet_discovery: AsyncMock, + mock_droplet_connection: AsyncMock, + mock_droplet: AsyncMock, +) -> None: + """Test integration setup when Droplet is offline.""" + mock_droplet.connected = False + await setup_integration(hass, mock_config_entry) + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/droplet/test_sensor.py b/tests/components/droplet/test_sensor.py new file mode 100644 index 00000000000000..9dcc72403f68c5 --- /dev/null +++ b/tests/components/droplet/test_sensor.py @@ -0,0 +1,46 @@ +"""Test Droplet sensors.""" + +from unittest.mock import AsyncMock + +from syrupy.assertion import SnapshotAssertion + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er + +from . import setup_integration + +from tests.common import MockConfigEntry, snapshot_platform + + +async def test_sensors( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_droplet_discovery: AsyncMock, + mock_droplet_connection: AsyncMock, + mock_droplet: AsyncMock, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, +) -> None: + """Test Droplet sensors.""" + await setup_integration(hass, mock_config_entry) + + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + +async def test_sensors_update_data( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_droplet_discovery: AsyncMock, + mock_droplet_connection: AsyncMock, + mock_droplet: AsyncMock, +) -> None: + """Test Droplet async update data.""" + await setup_integration(hass, mock_config_entry) + + assert hass.states.get("sensor.mock_title_flow_rate").state == "0.0264172052358148" + + mock_droplet.get_flow_rate.return_value = 0.5 + + mock_droplet.listen_forever.call_args_list[0][0][1]({}) + + assert hass.states.get("sensor.mock_title_flow_rate").state == "0.132086026179074" diff --git a/tests/components/dsmr/test_config_flow.py b/tests/components/dsmr/test_config_flow.py index 961c9831f44daf..c6031781c66d62 100644 --- a/tests/components/dsmr/test_config_flow.py +++ b/tests/components/dsmr/test_config_flow.py @@ -85,7 +85,7 @@ async def test_setup_network_rfxtrx( ], ) -> None: """Test we can setup network.""" - (connection_factory, transport, protocol) = dsmr_connection_send_validate_fixture + (_connection_factory, _transport, protocol) = dsmr_connection_send_validate_fixture result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} @@ -245,7 +245,7 @@ async def test_setup_serial_rfxtrx( ], ) -> None: """Test we can setup serial.""" - (connection_factory, transport, protocol) = dsmr_connection_send_validate_fixture + (_connection_factory, _transport, protocol) = dsmr_connection_send_validate_fixture port = com_port() @@ -344,7 +344,7 @@ async def test_setup_serial_fail( dsmr_connection_send_validate_fixture: tuple[MagicMock, MagicMock, MagicMock], ) -> None: """Test failed serial connection.""" - (connection_factory, transport, protocol) = dsmr_connection_send_validate_fixture + (_connection_factory, transport, protocol) = dsmr_connection_send_validate_fixture port = com_port() @@ -395,10 +395,10 @@ async def test_setup_serial_timeout( ], ) -> None: """Test failed serial connection.""" - (connection_factory, transport, protocol) = dsmr_connection_send_validate_fixture + (_connection_factory, _transport, protocol) = dsmr_connection_send_validate_fixture ( - connection_factory, - transport, + _connection_factory, + _transport, rfxtrx_protocol, ) = rfxtrx_dsmr_connection_send_validate_fixture @@ -453,10 +453,10 @@ async def test_setup_serial_wrong_telegram( ], ) -> None: """Test failed telegram data.""" - (connection_factory, transport, protocol) = dsmr_connection_send_validate_fixture + (_connection_factory, _transport, protocol) = dsmr_connection_send_validate_fixture ( - rfxtrx_connection_factory, - transport, + _rfxtrx_connection_factory, + _transport, rfxtrx_protocol, ) = rfxtrx_dsmr_connection_send_validate_fixture diff --git a/tests/components/dsmr/test_diagnostics.py b/tests/components/dsmr/test_diagnostics.py index 9bcde251f6fdb9..f2a475097ae6f1 100644 --- a/tests/components/dsmr/test_diagnostics.py +++ b/tests/components/dsmr/test_diagnostics.py @@ -26,7 +26,7 @@ async def test_diagnostics( snapshot: SnapshotAssertion, ) -> None: """Test diagnostics.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", diff --git a/tests/components/dsmr/test_mbus_migration.py b/tests/components/dsmr/test_mbus_migration.py index d590666b0607d7..8ad4114713512a 100644 --- a/tests/components/dsmr/test_mbus_migration.py +++ b/tests/components/dsmr/test_mbus_migration.py @@ -27,7 +27,7 @@ async def test_migrate_gas_to_mbus( dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock], ) -> None: """Test migration of unique_id.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture mock_entry = MockConfigEntry( domain=DOMAIN, @@ -138,7 +138,7 @@ async def test_migrate_hourly_gas_to_mbus( dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock], ) -> None: """Test migration of unique_id.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture mock_entry = MockConfigEntry( domain=DOMAIN, @@ -249,7 +249,7 @@ async def test_migrate_gas_with_devid_to_mbus( dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock], ) -> None: """Test migration of unique_id.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture mock_entry = MockConfigEntry( domain=DOMAIN, @@ -357,7 +357,7 @@ async def test_migrate_gas_to_mbus_exists( caplog: pytest.LogCaptureFixture, ) -> None: """Test migration of unique_id.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture mock_entry = MockConfigEntry( domain=DOMAIN, diff --git a/tests/components/dsmr/test_sensor.py b/tests/components/dsmr/test_sensor.py index 5657c5999ce9e3..7e01431a5dc3c0 100644 --- a/tests/components/dsmr/test_sensor.py +++ b/tests/components/dsmr/test_sensor.py @@ -57,7 +57,7 @@ async def test_default_setup( dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock], ) -> None: """Test the default setup.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", @@ -205,7 +205,7 @@ async def test_setup_only_energy( dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock], ) -> None: """Test the default setup.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", @@ -260,7 +260,7 @@ async def test_v4_meter( hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] ) -> None: """Test if v4 meter is correctly parsed.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", @@ -348,7 +348,7 @@ async def test_v5_meter( state: str, ) -> None: """Test if v5 meter is correctly parsed.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", @@ -421,7 +421,7 @@ async def test_luxembourg_meter( hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] ) -> None: """Test if v5 meter is correctly parsed.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", @@ -516,7 +516,7 @@ async def test_eonhu_meter( hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] ) -> None: """Test if v5 meter is correctly parsed.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", @@ -586,7 +586,7 @@ async def test_belgian_meter( hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] ) -> None: """Test if Belgian meter is correctly parsed.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", @@ -820,7 +820,7 @@ async def test_belgian_meter_alt( hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] ) -> None: """Test if Belgian meter is correctly parsed.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", @@ -1008,7 +1008,7 @@ async def test_belgian_meter_mbus( hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] ) -> None: """Test if Belgian meter is correctly parsed.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", @@ -1158,7 +1158,7 @@ async def test_belgian_meter_low( hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] ) -> None: """Test if Belgian meter is correctly parsed.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", @@ -1207,7 +1207,7 @@ async def test_swedish_meter( hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] ) -> None: """Test if v5 meter is correctly parsed.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", @@ -1282,7 +1282,7 @@ async def test_easymeter( hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] ) -> None: """Test if Q3D meter is correctly parsed.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", @@ -1360,7 +1360,7 @@ async def test_tcp( hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] ) -> None: """If proper config provided TCP connection should be made.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "host": "localhost", @@ -1389,7 +1389,7 @@ async def test_rfxtrx_tcp( rfxtrx_dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock], ) -> None: """If proper config provided RFXtrx TCP connection should be made.""" - (connection_factory, transport, protocol) = rfxtrx_dsmr_connection_fixture + (connection_factory, _transport, _protocol) = rfxtrx_dsmr_connection_fixture entry_data = { "host": "localhost", @@ -1418,7 +1418,7 @@ async def test_connection_errors_retry( hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] ) -> None: """Connection should be retried on error during setup.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (_connection_factory, transport, protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", @@ -1457,7 +1457,7 @@ async def test_reconnect( ) -> None: """If transport disconnects, the connection should be retried.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", @@ -1540,7 +1540,7 @@ async def test_gas_meter_providing_energy_reading( hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] ) -> None: """Test that gas providing energy readings use the correct device class.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", @@ -1595,7 +1595,7 @@ async def test_heat_meter_mbus( hass: HomeAssistant, dsmr_connection_fixture: tuple[MagicMock, MagicMock, MagicMock] ) -> None: """Test if heat meter reading is correctly parsed.""" - (connection_factory, transport, protocol) = dsmr_connection_fixture + (connection_factory, _transport, _protocol) = dsmr_connection_fixture entry_data = { "port": "/dev/ttyUSB0", diff --git a/tests/components/evohome/test_storage.py b/tests/components/evohome/test_storage.py index 4528f1c8590e77..3aae5e3705fe1b 100644 --- a/tests/components/evohome/test_storage.py +++ b/tests/components/evohome/test_storage.py @@ -139,7 +139,7 @@ async def test_auth_tokens_past( ) -> None: """Test credentials manager when cache contains expired data for this user.""" - dt_dtm, dt_str = dt_pair(dt_util.now() - timedelta(hours=1)) + _dt_dtm, dt_str = dt_pair(dt_util.now() - timedelta(hours=1)) # make this access token have expired in the past... test_data = TEST_STORAGE_DATA[idx].copy() # shallow copy is OK here diff --git a/tests/components/google_wifi/test_sensor.py b/tests/components/google_wifi/test_sensor.py index 88adcbf65871a8..cb8cd15ca5dd1d 100644 --- a/tests/components/google_wifi/test_sensor.py +++ b/tests/components/google_wifi/test_sensor.py @@ -117,7 +117,7 @@ def fake_delay(hass: HomeAssistant, ha_delay: int) -> None: def test_name(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: """Test the name.""" - api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) + _api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) for value in sensor_dict.values(): sensor = value["sensor"] sensor.platform = MockEntityPlatform(hass) @@ -129,7 +129,7 @@ def test_unit_of_measurement( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: """Test the unit of measurement.""" - api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) + _api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) for value in sensor_dict.values(): sensor = value["sensor"] assert value["units"] == sensor.unit_of_measurement @@ -137,7 +137,7 @@ def test_unit_of_measurement( def test_icon(requests_mock: requests_mock.Mocker) -> None: """Test the icon.""" - api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) + _api, sensor_dict = setup_api(None, MOCK_DATA, requests_mock) for value in sensor_dict.values(): sensor = value["sensor"] assert value["icon"] == sensor.icon @@ -145,7 +145,7 @@ def test_icon(requests_mock: requests_mock.Mocker) -> None: def test_state(hass: HomeAssistant, requests_mock: requests_mock.Mocker) -> None: """Test the initial state.""" - api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) + _api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): for name, value in sensor_dict.items(): @@ -166,7 +166,7 @@ def test_update_when_value_is_none( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: """Test state gets updated to unknown when sensor returns no data.""" - api, sensor_dict = setup_api(hass, None, requests_mock) + _api, sensor_dict = setup_api(hass, None, requests_mock) for value in sensor_dict.values(): sensor = value["sensor"] fake_delay(hass, 2) @@ -178,7 +178,7 @@ def test_update_when_value_changed( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: """Test state gets updated when sensor returns a new status.""" - api, sensor_dict = setup_api(hass, MOCK_DATA_NEXT, requests_mock) + _api, sensor_dict = setup_api(hass, MOCK_DATA_NEXT, requests_mock) now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): for name, value in sensor_dict.items(): @@ -203,7 +203,7 @@ def test_when_api_data_missing( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: """Test state logs an error when data is missing.""" - api, sensor_dict = setup_api(hass, MOCK_DATA_MISSING, requests_mock) + _api, sensor_dict = setup_api(hass, MOCK_DATA_MISSING, requests_mock) now = datetime(1970, month=1, day=1) with patch("homeassistant.util.dt.now", return_value=now): for value in sensor_dict.values(): @@ -232,6 +232,6 @@ def update_side_effect( hass: HomeAssistant, requests_mock: requests_mock.Mocker ) -> None: """Mock representation of update function.""" - api, sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) + api, _sensor_dict = setup_api(hass, MOCK_DATA, requests_mock) api.data = None api.available = False diff --git a/tests/components/homekit_controller/specific_devices/test_connectsense.py b/tests/components/homekit_controller/specific_devices/test_connectsense.py index b3190c510fdc5a..82c8a1b810298e 100644 --- a/tests/components/homekit_controller/specific_devices/test_connectsense.py +++ b/tests/components/homekit_controller/specific_devices/test_connectsense.py @@ -17,7 +17,7 @@ async def test_connectsense_setup(hass: HomeAssistant) -> None: """Test that the accessory can be correctly setup in HA.""" accessories = await setup_accessories_from_file(hass, "connectsense.json") - config_entry, pairing = await setup_test_accessories(hass, accessories) + config_entry, _pairing = await setup_test_accessories(hass, accessories) await assert_devices_and_entities_created( hass, diff --git a/tests/components/homekit_controller/specific_devices/test_ecobee3.py b/tests/components/homekit_controller/specific_devices/test_ecobee3.py index 059993e3befce6..e79d3ab3edb49d 100644 --- a/tests/components/homekit_controller/specific_devices/test_ecobee3.py +++ b/tests/components/homekit_controller/specific_devices/test_ecobee3.py @@ -190,7 +190,7 @@ async def test_ecobee3_setup_connection_failure( # If there is no cached entity map and the accessory connection is # failing then we have to fail the config entry setup. - config_entry, pairing = await setup_test_accessories(hass, accessories) + config_entry, _pairing = await setup_test_accessories(hass, accessories) assert config_entry.state is ConfigEntryState.SETUP_RETRY climate = entity_registry.async_get("climate.homew") diff --git a/tests/components/homematicip_cloud/test_binary_sensor.py b/tests/components/homematicip_cloud/test_binary_sensor.py index 4f6913cc8e892c..90af26bee55fcd 100644 --- a/tests/components/homematicip_cloud/test_binary_sensor.py +++ b/tests/components/homematicip_cloud/test_binary_sensor.py @@ -38,7 +38,7 @@ async def test_hmip_home_cloud_connection_sensor( test_devices=[entity_name] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) diff --git a/tests/components/homematicip_cloud/test_climate.py b/tests/components/homematicip_cloud/test_climate.py index 67dbb55bb1218d..4f0283daa68e57 100644 --- a/tests/components/homematicip_cloud/test_climate.py +++ b/tests/components/homematicip_cloud/test_climate.py @@ -490,7 +490,7 @@ async def test_hmip_heating_profile_name_not_in_list( test_devices=["Heizkörperthermostat2"], test_groups=[entity_name], ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) diff --git a/tests/components/homematicip_cloud/test_device.py b/tests/components/homematicip_cloud/test_device.py index 8bff1798255124..8c9ffc7dfd43ee 100644 --- a/tests/components/homematicip_cloud/test_device.py +++ b/tests/components/homematicip_cloud/test_device.py @@ -280,7 +280,7 @@ async def test_hmip_multi_area_device( test_devices=["Wired Eingangsmodul – 32-fach"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) assert ha_state diff --git a/tests/components/homematicip_cloud/test_light.py b/tests/components/homematicip_cloud/test_light.py index 85106f2d987031..be432eaae31501 100644 --- a/tests/components/homematicip_cloud/test_light.py +++ b/tests/components/homematicip_cloud/test_light.py @@ -241,7 +241,7 @@ async def test_hmip_notification_light_2_turn_off( device_model = "HmIP-BSL" mock_hap = await default_mock_hap_factory.async_get_mock_hap(test_devices=["BSL2"]) - ha_state, hmip_device = get_and_check_entity_basics( + _ha_state, hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) diff --git a/tests/components/homematicip_cloud/test_sensor.py b/tests/components/homematicip_cloud/test_sensor.py index 669cbbf664ff40..825f3ab042da11 100644 --- a/tests/components/homematicip_cloud/test_sensor.py +++ b/tests/components/homematicip_cloud/test_sensor.py @@ -565,7 +565,7 @@ async def test_hmip_esi_iec_current_power_consumption( test_devices=["esi_iec"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -583,7 +583,7 @@ async def test_hmip_esi_iec_energy_counter_usage_high_tariff( test_devices=["esi_iec"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -601,7 +601,7 @@ async def test_hmip_esi_iec_energy_counter_usage_low_tariff( test_devices=["esi_iec"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -619,7 +619,7 @@ async def test_hmip_esi_iec_energy_counter_input_single_tariff( test_devices=["esi_iec"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -652,7 +652,7 @@ async def test_hmip_esi_gas_current_gas_flow( test_devices=["esi_gas"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -670,7 +670,7 @@ async def test_hmip_esi_gas_gas_volume( test_devices=["esi_gas"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -688,7 +688,7 @@ async def test_hmip_esi_led_current_power_consumption( test_devices=["esi_led"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -706,7 +706,7 @@ async def test_hmip_esi_led_energy_counter_usage_high_tariff( test_devices=["esi_led"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -754,7 +754,7 @@ async def test_hmip_tilt_vibration_sensor_tilt_angle( test_devices=["Neigungssensor Tor"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -772,7 +772,7 @@ async def test_hmip_absolute_humidity_sensor( test_devices=["elvshctv"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -811,7 +811,7 @@ async def test_hmip_water_valve_current_water_flow( test_devices=["Bewaesserungsaktor"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -834,7 +834,7 @@ async def test_hmip_water_valve_water_volume( test_devices=["Bewaesserungsaktor"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) @@ -854,7 +854,7 @@ async def test_hmip_water_valve_water_volume_since_open( test_devices=["Bewaesserungsaktor"] ) - ha_state, hmip_device = get_and_check_entity_basics( + ha_state, _hmip_device = get_and_check_entity_basics( hass, mock_hap, entity_id, entity_name, device_model ) diff --git a/tests/components/insteon/test_api_config.py b/tests/components/insteon/test_api_config.py index 9d38b70c850874..bbefb34fe93a38 100644 --- a/tests/components/insteon/test_api_config.py +++ b/tests/components/insteon/test_api_config.py @@ -68,7 +68,7 @@ async def test_get_modem_schema_hub( ) -> None: """Test getting the Insteon PLM modem configuration schema.""" - ws_client, devices, _, _ = await async_mock_setup( + ws_client, _devices, _, _ = await async_mock_setup( hass, hass_ws_client, config_data={**MOCK_USER_INPUT_HUB_V2, CONF_HUB_VERSION: 2}, diff --git a/tests/components/insteon/test_api_properties.py b/tests/components/insteon/test_api_properties.py index aeeeeab3d7b729..2d15132e5ffbe5 100644 --- a/tests/components/insteon/test_api_properties.py +++ b/tests/components/insteon/test_api_properties.py @@ -494,7 +494,7 @@ async def test_bad_address( hass: HomeAssistant, hass_ws_client: WebSocketGenerator, kpl_properties_data ) -> None: """Test for a bad Insteon address.""" - ws_client, devices = await _setup( + ws_client, _devices = await _setup( hass, hass_ws_client, "33.33.33", kpl_properties_data ) diff --git a/tests/components/insteon/test_config_flow.py b/tests/components/insteon/test_config_flow.py index 33e71be6dc2ffa..32cedc3c202c8c 100644 --- a/tests/components/insteon/test_config_flow.py +++ b/tests/components/insteon/test_config_flow.py @@ -247,7 +247,7 @@ async def test_failed_connection_plm_manually(hass: HomeAssistant) -> None: result = await _init_form(hass, STEP_PLM) - result2, _ = await _device_form( + _result2, _ = await _device_form( hass, result["flow_id"], mock_successful_connection, MOCK_USER_INPUT_PLM_MANUAL ) result3, _ = await _device_form( diff --git a/tests/components/intellifire/test_config_flow.py b/tests/components/intellifire/test_config_flow.py index 96d0fa17e635b8..49ce6b91e96515 100644 --- a/tests/components/intellifire/test_config_flow.py +++ b/tests/components/intellifire/test_config_flow.py @@ -79,7 +79,7 @@ async def test_standard_config_with_single_fireplace_and_bad_credentials( mock_apis_single_fp, ) -> None: """Test bad credentials on a login.""" - mock_local_interface, mock_cloud_interface, mock_fp = mock_apis_single_fp + _mock_local_interface, mock_cloud_interface, _mock_fp = mock_apis_single_fp # Set login error mock_cloud_interface.login_with_credentials.side_effect = LoginError @@ -190,7 +190,7 @@ async def test_dhcp_discovery_non_intellifire_device( """Test successful DHCP Discovery of a non intellifire device..""" # Patch poll with an exception - mock_local_interface, mock_cloud_interface, mock_fp = mock_apis_multifp + mock_local_interface, _mock_cloud_interface, _mock_fp = mock_apis_multifp mock_local_interface.poll.side_effect = ConnectionError result = await hass.config_entries.flow.async_init( diff --git a/tests/components/matter/snapshots/test_light.ambr b/tests/components/matter/snapshots/test_light.ambr index 83b953c9b04986..e62c6696c1c637 100644 --- a/tests/components/matter/snapshots/test_light.ambr +++ b/tests/components/matter/snapshots/test_light.ambr @@ -281,6 +281,62 @@ 'state': 'on', }) # --- +# name: test_lights[mounted_dimmable_load_control_fixture][light.mock_mounted_dimmable_load_control-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'supported_color_modes': list([ + , + ]), + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'light', + 'entity_category': None, + 'entity_id': 'light.mock_mounted_dimmable_load_control', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': None, + 'original_icon': None, + 'original_name': None, + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': , + 'translation_key': None, + 'unique_id': '00000000000004D2-000000000000000E-MatterNodeDevice-1-MatterLight-6-0', + 'unit_of_measurement': None, + }) +# --- +# name: test_lights[mounted_dimmable_load_control_fixture][light.mock_mounted_dimmable_load_control-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Mock Mounted dimmable load control', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.mock_mounted_dimmable_load_control', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_lights[multi_endpoint_light][light.inovelli_light_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/snapshots/test_switch.ambr b/tests/components/matter/snapshots/test_switch.ambr index 01881448e13dc0..cdd2f65a61ed28 100644 --- a/tests/components/matter/snapshots/test_switch.ambr +++ b/tests/components/matter/snapshots/test_switch.ambr @@ -390,55 +390,6 @@ 'state': 'off', }) # --- -# name: test_switches[mounted_dimmable_load_control_fixture][switch.mock_mounted_dimmable_load_control-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': None, - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'switch', - 'entity_category': None, - 'entity_id': 'switch.mock_mounted_dimmable_load_control', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': None, - 'platform': 'matter', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '00000000000004D2-000000000000000E-MatterNodeDevice-1-MatterSwitch-6-0', - 'unit_of_measurement': None, - }) -# --- -# name: test_switches[mounted_dimmable_load_control_fixture][switch.mock_mounted_dimmable_load_control-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'outlet', - 'friendly_name': 'Mock Mounted dimmable load control', - }), - 'context': , - 'entity_id': 'switch.mock_mounted_dimmable_load_control', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'unavailable', - }) -# --- # name: test_switches[on_off_plugin_unit][switch.mock_onoffpluginunit-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/nest/test_api.py b/tests/components/nest/test_api.py index 1a5c4d63dba240..ab0601605253f9 100644 --- a/tests/components/nest/test_api.py +++ b/tests/components/nest/test_api.py @@ -71,9 +71,9 @@ def async_new_subscriber( # Verify API requests are made with the correct credentials calls = aioclient_mock.mock_calls assert len(calls) == 2 - (method, url, data, headers) = calls[0] + (_method, _url, _data, headers) = calls[0] assert headers == {"Authorization": f"Bearer {FAKE_TOKEN}"} - (method, url, data, headers) = calls[1] + (_method, _url, _data, headers) = calls[1] assert headers == {"Authorization": f"Bearer {FAKE_TOKEN}"} # Verify the subscriber was created with the correct credentials diff --git a/tests/components/nibe_heatpump/test_climate.py b/tests/components/nibe_heatpump/test_climate.py index 85e932f80189fa..039113892c1022 100644 --- a/tests/components/nibe_heatpump/test_climate.py +++ b/tests/components/nibe_heatpump/test_climate.py @@ -124,7 +124,7 @@ async def test_active_accessory( snapshot: SnapshotAssertion, ) -> None: """Test climate groups that can be deactivated by configuration.""" - climate, unit = _setup_climate_group(coils, model, climate_id) + climate, _unit = _setup_climate_group(coils, model, climate_id) await async_add_model(hass, model) diff --git a/tests/components/onvif/test_button.py b/tests/components/onvif/test_button.py index 209733a0f78191..60fada5e06a5ee 100644 --- a/tests/components/onvif/test_button.py +++ b/tests/components/onvif/test_button.py @@ -60,7 +60,7 @@ async def test_set_dateandtime_button( async def test_set_dateandtime_button_press(hass: HomeAssistant) -> None: """Test SetDateAndTime button press.""" - _, camera, device = await setup_onvif_integration(hass) + _, _camera, device = await setup_onvif_integration(hass) device.async_manually_set_date_and_time = AsyncMock(return_value=True) await hass.services.async_call( diff --git a/tests/components/openuv/snapshots/test_binary_sensor.ambr b/tests/components/openuv/snapshots/test_binary_sensor.ambr index ef52d36fb6eb99..8c88392f7cc2f7 100644 --- a/tests/components/openuv/snapshots/test_binary_sensor.ambr +++ b/tests/components/openuv/snapshots/test_binary_sensor.ambr @@ -51,3 +51,54 @@ 'state': 'off', }) # --- +# name: test_protection_window_recalculation[after-protetction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'end_time': datetime.datetime(2018, 7, 30, 16, 47, 49, 750000, tzinfo=zoneinfo.ZoneInfo(key='America/Regina')), + 'end_uv': 3.6483, + 'friendly_name': 'OpenUV Protection window', + 'start_time': datetime.datetime(2018, 7, 30, 9, 17, 49, 750000, tzinfo=zoneinfo.ZoneInfo(key='America/Regina')), + 'start_uv': 3.2509, + }), + 'context': , + 'entity_id': 'binary_sensor.openuv_protection_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_protection_window_recalculation[before-protetction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'end_time': datetime.datetime(2018, 7, 30, 16, 47, 49, 750000, tzinfo=zoneinfo.ZoneInfo(key='America/Regina')), + 'end_uv': 3.6483, + 'friendly_name': 'OpenUV Protection window', + 'start_time': datetime.datetime(2018, 7, 30, 9, 17, 49, 750000, tzinfo=zoneinfo.ZoneInfo(key='America/Regina')), + 'start_uv': 3.2509, + }), + 'context': , + 'entity_id': 'binary_sensor.openuv_protection_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'off', + }) +# --- +# name: test_protection_window_recalculation[during-protetction-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'end_time': datetime.datetime(2018, 7, 30, 16, 47, 49, 750000, tzinfo=zoneinfo.ZoneInfo(key='America/Regina')), + 'end_uv': 3.6483, + 'friendly_name': 'OpenUV Protection window', + 'start_time': datetime.datetime(2018, 7, 30, 9, 17, 49, 750000, tzinfo=zoneinfo.ZoneInfo(key='America/Regina')), + 'start_uv': 3.2509, + }), + 'context': , + 'entity_id': 'binary_sensor.openuv_protection_window', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/openuv/test_binary_sensor.py b/tests/components/openuv/test_binary_sensor.py index d6025b9ed20429..1885966c4f9b54 100644 --- a/tests/components/openuv/test_binary_sensor.py +++ b/tests/components/openuv/test_binary_sensor.py @@ -1,21 +1,26 @@ """Test OpenUV binary sensors.""" -from typing import Literal from unittest.mock import patch +from freezegun.api import FrozenDateTimeFactory from syrupy.assertion import SnapshotAssertion -from homeassistant.const import Platform +from homeassistant.components.homeassistant import ( + DOMAIN as HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform 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 homeassistant.setup import async_setup_component -from tests.common import MockConfigEntry, snapshot_platform +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform async def test_binary_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_pyopenuv: Literal[None], + mock_pyopenuv: None, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, ) -> None: @@ -25,3 +30,77 @@ async def test_binary_sensors( await hass.async_block_till_done() await snapshot_platform(hass, entity_registry, snapshot, config_entry.entry_id) + + +async def test_protetction_window_update( + hass: HomeAssistant, + set_time_zone, + config, + client, + config_entry, + setup_config_entry, + snapshot: SnapshotAssertion, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that updating the protetection window makes an extra API call.""" + + assert await async_setup_component(hass, HOMEASSISTANT_DOMAIN, {}) + + assert client.uv_protection_window.call_count == 1 + + await hass.services.async_call( + HOMEASSISTANT_DOMAIN, + SERVICE_UPDATE_ENTITY, + {ATTR_ENTITY_ID: "binary_sensor.openuv_protection_window"}, + blocking=True, + ) + + assert client.uv_protection_window.call_count == 2 + + +async def test_protection_window_recalculation( + hass: HomeAssistant, + config, + config_entry, + snapshot: SnapshotAssertion, + set_time_zone, + mock_pyopenuv, + client, + freezer: FrozenDateTimeFactory, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, +) -> None: + """Test that protetction window updates automatically without extra API calls.""" + + freezer.move_to("2018-07-30T06:17:59-06:00") + + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + entity_id = "binary_sensor.openuv_protection_window" + state = hass.states.get(entity_id) + assert state.state == "off" + assert state == snapshot(name="before-protetction-state") + + # move to when the protetction window starts + freezer.move_to("2018-07-30T09:17:59-06:00") + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_id = "binary_sensor.openuv_protection_window" + state = hass.states.get(entity_id) + assert state.state == "on" + assert state == snapshot(name="during-protetction-state") + + # move to when the protetction window ends + freezer.move_to("2018-07-30T16:47:59-06:00") + async_fire_time_changed(hass) + await hass.async_block_till_done() + + entity_id = "binary_sensor.openuv_protection_window" + state = hass.states.get(entity_id) + assert state.state == "off" + assert state == snapshot(name="after-protetction-state") + + assert client.uv_protection_window.call_count == 1 diff --git a/tests/components/openuv/test_diagnostics.py b/tests/components/openuv/test_diagnostics.py index 03b392b3e7b1ab..27cf79ae200155 100644 --- a/tests/components/openuv/test_diagnostics.py +++ b/tests/components/openuv/test_diagnostics.py @@ -43,9 +43,10 @@ async def test_entry_diagnostics( }, "data": { "protection_window": { - "from_time": "2018-07-30T15:17:49.750Z", + "is_on": False, + "from_time": "2018-07-30T15:17:49.750000+00:00", "from_uv": 3.2509, - "to_time": "2018-07-30T22:47:49.750Z", + "to_time": "2018-07-30T22:47:49.750000+00:00", "to_uv": 3.6483, }, "uv": { diff --git a/tests/components/openuv/test_sensor.py b/tests/components/openuv/test_sensor.py index 93106aedc351ba..d91095afd08aad 100644 --- a/tests/components/openuv/test_sensor.py +++ b/tests/components/openuv/test_sensor.py @@ -1,6 +1,5 @@ """Test OpenUV sensors.""" -from typing import Literal from unittest.mock import patch from syrupy.assertion import SnapshotAssertion @@ -15,7 +14,7 @@ async def test_sensors( hass: HomeAssistant, config_entry: MockConfigEntry, - mock_pyopenuv: Literal[None], + mock_pyopenuv: None, snapshot: SnapshotAssertion, entity_registry: er.EntityRegistry, ) -> None: diff --git a/tests/components/portainer/__init__.py b/tests/components/portainer/__init__.py new file mode 100644 index 00000000000000..ec381f4210700d --- /dev/null +++ b/tests/components/portainer/__init__.py @@ -0,0 +1,13 @@ +"""Tests for the Portainer integration.""" + +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def setup_integration(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + """Fixture for setting up the component.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/portainer/conftest.py b/tests/components/portainer/conftest.py new file mode 100644 index 00000000000000..2d0f8e34d33f5a --- /dev/null +++ b/tests/components/portainer/conftest.py @@ -0,0 +1,63 @@ +"""Common fixtures for the portainer tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, patch + +from pyportainer.models.docker import DockerContainer +from pyportainer.models.portainer import Endpoint +import pytest + +from homeassistant.components.portainer.const import DOMAIN +from homeassistant.const import CONF_API_KEY, CONF_HOST + +from tests.common import MockConfigEntry, load_json_array_fixture + +MOCK_TEST_CONFIG = { + CONF_HOST: "https://127.0.0.1:9000/", + CONF_API_KEY: "test_api_key", +} + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.portainer.async_setup_entry", return_value=True + ) as mock_setup_entry: + yield mock_setup_entry + + +@pytest.fixture +def mock_portainer_client() -> Generator[AsyncMock]: + """Mock Portainer client with dynamic exception injection support.""" + with ( + patch( + "homeassistant.components.portainer.Portainer", autospec=True + ) as mock_client, + patch( + "homeassistant.components.portainer.config_flow.Portainer", new=mock_client + ), + ): + client = mock_client.return_value + + client.get_endpoints.return_value = [ + Endpoint.from_dict(endpoint) + for endpoint in load_json_array_fixture("endpoints.json", DOMAIN) + ] + client.get_containers.return_value = [ + DockerContainer.from_dict(container) + for container in load_json_array_fixture("containers.json", DOMAIN) + ] + + yield client + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + title="Portainer test", + data=MOCK_TEST_CONFIG, + entry_id="portainer_test_entry_123", + ) diff --git a/tests/components/portainer/fixtures/containers.json b/tests/components/portainer/fixtures/containers.json new file mode 100644 index 00000000000000..a70da630549053 --- /dev/null +++ b/tests/components/portainer/fixtures/containers.json @@ -0,0 +1,166 @@ +[ + { + "Id": "aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + "Names": ["/funny_chatelet"], + "Image": "docker.io/library/ubuntu:latest", + "ImageID": "sha256:72297848456d5d37d1262630108ab308d3e9ec7ed1c3286a32fe09856619a782", + "ImageManifestDescriptor": { + "mediaType": "application/vnd.oci.image.manifest.v1+json", + "digest": "sha256:c0537ff6a5218ef531ece93d4984efc99bbf3f7497c0a7726c88e2bb7584dc96", + "size": 424, + "urls": ["http://example.com"], + "annotations": { + "com.docker.official-images.bashbrew.arch": "amd64", + "org.opencontainers.image.base.digest": "sha256:0d0ef5c914d3ea700147da1bd050c59edb8bb12ca312f3800b29d7c8087eabd8", + "org.opencontainers.image.base.name": "scratch", + "org.opencontainers.image.created": "2025-01-27T00:00:00Z", + "org.opencontainers.image.revision": "9fabb4bad5138435b01857e2fe9363e2dc5f6a79", + "org.opencontainers.image.source": "https://git.launchpad.net/cloud-images/+oci/ubuntu-base", + "org.opencontainers.image.url": "https://hub.docker.com/_/ubuntu", + "org.opencontainers.image.version": "24.04" + }, + "data": null, + "platform": { + "architecture": "arm", + "os": "windows", + "os.version": "10.0.19041.1165", + "os.features": ["win32k"], + "variant": "v7" + }, + "artifactType": null + }, + "Command": "/bin/bash", + "Created": "1739811096", + "Ports": [ + { + "PrivatePort": 8080, + "PublicPort": 80, + "Type": "tcp" + } + ], + "SizeRw": "122880", + "SizeRootFs": "1653948416", + "Labels": { + "com.example.vendor": "Acme", + "com.example.license": "GPL", + "com.example.version": "1.0" + }, + "State": "running", + "Status": "Up 4 days", + "HostConfig": { + "NetworkMode": "mynetwork", + "Annotations": { + "io.kubernetes.docker.type": "container", + "io.kubernetes.sandbox.id": "3befe639bed0fd6afdd65fd1fa84506756f59360ec4adc270b0fdac9be22b4d3" + } + }, + "NetworkSettings": { + "Networks": { + "property1": { + "IPAMConfig": { + "IPv4Address": "172.20.30.33", + "IPv6Address": "2001:db8:abcd::3033", + "LinkLocalIPs": ["169.254.34.68", "fe80::3468"] + }, + "Links": ["container_1", "container_2"], + "MacAddress": "02:42:ac:11:00:04", + "Aliases": ["server_x", "server_y"], + "DriverOpts": { + "com.example.some-label": "some-value", + "com.example.some-other-label": "some-other-value" + }, + "GwPriority": [10], + "NetworkID": "08754567f1f40222263eab4102e1c733ae697e8e354aa9cd6e18d7402835292a", + "EndpointID": "b88f5b905aabf2893f3cbc4ee42d1ea7980bbc0a92e2c8922b1e1795298afb0b", + "Gateway": "172.17.0.1", + "IPAddress": "172.17.0.4", + "IPPrefixLen": 16, + "IPv6Gateway": "2001:db8:2::100", + "GlobalIPv6Address": "2001:db8::5689", + "GlobalIPv6PrefixLen": 64, + "DNSNames": ["foobar", "server_x", "server_y", "my.ctr"] + } + } + }, + "Mounts": [ + { + "Type": "volume", + "Name": "myvolume", + "Source": "/var/lib/docker/volumes/myvolume/_data", + "Destination": "/usr/share/nginx/html/", + "Driver": "local", + "Mode": "z", + "RW": true, + "Propagation": "" + } + ] + }, + { + "Id": "bb97facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + "Names": ["/serene_banach"], + "Image": "docker.io/library/nginx:latest", + "ImageID": "sha256:3f8a4339a5c5d5d37d1262630108ab308d3e9ec7ed1c3286a32fe09856619a782", + "Command": "nginx -g 'daemon off;'", + "Created": "1739812096", + "Ports": [ + { + "PrivatePort": 80, + "PublicPort": 8081, + "Type": "tcp" + } + ], + "State": "running", + "Status": "Up 2 days" + }, + { + "Id": "cc08facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + "Names": ["/stoic_turing"], + "Image": "docker.io/library/postgres:15", + "ImageID": "sha256:4f8a4339a5c5d5d37d1262630108ab308d3e9ec7ed1c3286a32fe09856619a782", + "Command": "postgres", + "Created": "1739813096", + "Ports": [ + { + "PrivatePort": 5432, + "PublicPort": 5432, + "Type": "tcp" + } + ], + "State": "running", + "Status": "Up 1 day" + }, + { + "Id": "dd19facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + "Names": ["/focused_einstein"], + "Image": "docker.io/library/redis:7", + "ImageID": "sha256:5f8a4339a5c5d5d37d1262630108ab308d3e9ec7ed1c3286a32fe09856619a782", + "Command": "redis-server", + "Created": "1739814096", + "Ports": [ + { + "PrivatePort": 6379, + "PublicPort": 6379, + "Type": "tcp" + } + ], + "State": "running", + "Status": "Up 12 hours" + }, + { + "Id": "ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf", + "Names": ["/practical_morse"], + "Image": "docker.io/library/python:3.13-slim", + "ImageID": "sha256:6f8a4339a5c5d5d37d1262630108ab308d3e9ec7ed1c3286a32fe09856619a782", + "Command": "python3 -m http.server", + "Created": "1739815096", + "Ports": [ + { + "PrivatePort": 8000, + "PublicPort": 8000, + "Type": "tcp" + } + ], + "State": "running", + "Status": "Up 6 hours" + } +] diff --git a/tests/components/portainer/fixtures/endpoints.json b/tests/components/portainer/fixtures/endpoints.json new file mode 100644 index 00000000000000..95e728a4ac32a9 --- /dev/null +++ b/tests/components/portainer/fixtures/endpoints.json @@ -0,0 +1,195 @@ +[ + { + "AMTDeviceGUID": "4c4c4544-004b-3910-8037-b6c04f504633", + "AuthorizedTeams": [1], + "AuthorizedUsers": [1], + "AzureCredentials": { + "ApplicationID": "eag7cdo9-o09l-9i83-9dO9-f0b23oe78db4", + "AuthenticationKey": "cOrXoK/1D35w8YQ8nH1/8ZGwzz45JIYD5jxHKXEQknk=", + "TenantID": "34ddc78d-4fel-2358-8cc1-df84c8o839f5" + }, + "ComposeSyntaxMaxVersion": "3.8", + "ContainerEngine": "docker", + "EdgeCheckinInterval": 5, + "EdgeID": "string", + "EdgeKey": "string", + "EnableGPUManagement": true, + "Gpus": [ + { + "name": "name", + "value": "value" + } + ], + "GroupId": 1, + "Heartbeat": true, + "Id": 1, + "IsEdgeDevice": true, + "Kubernetes": { + "Configuration": { + "AllowNoneIngressClass": true, + "EnableResourceOverCommit": true, + "IngressAvailabilityPerNamespace": true, + "IngressClasses": [ + { + "Blocked": true, + "BlockedNamespaces": ["string"], + "Name": "string", + "Type": "string" + } + ], + "ResourceOverCommitPercentage": 0, + "RestrictDefaultNamespace": true, + "StorageClasses": [ + { + "AccessModes": ["string"], + "AllowVolumeExpansion": true, + "Name": "string", + "Provisioner": "string" + } + ], + "UseLoadBalancer": true, + "UseServerMetrics": true + }, + "Flags": { + "IsServerIngressClassDetected": true, + "IsServerMetricsDetected": true, + "IsServerStorageDetected": true + }, + "Snapshots": [ + { + "DiagnosticsData": { + "DNS": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + }, + "Log": "string", + "Proxy": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + }, + "Telnet": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + } + }, + "KubernetesVersion": "string", + "NodeCount": 0, + "Time": 0, + "TotalCPU": 0, + "TotalMemory": 0 + } + ] + }, + "Name": "my-environment", + "PostInitMigrations": { + "MigrateGPUs": true, + "MigrateIngresses": true + }, + "PublicURL": "docker.mydomain.tld:2375", + "Snapshots": [ + { + "ContainerCount": 0, + "DiagnosticsData": { + "DNS": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + }, + "Log": "string", + "Proxy": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + }, + "Telnet": { + "additionalProp1": "string", + "additionalProp2": "string", + "additionalProp3": "string" + } + }, + "DockerSnapshotRaw": {}, + "DockerVersion": "string", + "GpuUseAll": true, + "GpuUseList": ["string"], + "HealthyContainerCount": 0, + "ImageCount": 0, + "IsPodman": true, + "NodeCount": 0, + "RunningContainerCount": 0, + "ServiceCount": 0, + "StackCount": 0, + "StoppedContainerCount": 0, + "Swarm": true, + "Time": 0, + "TotalCPU": 0, + "TotalMemory": 0, + "UnhealthyContainerCount": 0, + "VolumeCount": 0 + } + ], + "Status": 1, + "TLS": true, + "TLSCACert": "string", + "TLSCert": "string", + "TLSConfig": { + "TLS": true, + "TLSCACert": "/data/tls/ca.pem", + "TLSCert": "/data/tls/cert.pem", + "TLSKey": "/data/tls/key.pem", + "TLSSkipVerify": false + }, + "TLSKey": "string", + "TagIds": [1], + "Tags": ["string"], + "TeamAccessPolicies": { + "additionalProp1": { + "RoleId": 1 + }, + "additionalProp2": { + "RoleId": 1 + }, + "additionalProp3": { + "RoleId": 1 + } + }, + "Type": 1, + "URL": "docker.mydomain.tld:2375", + "UserAccessPolicies": { + "additionalProp1": { + "RoleId": 1 + }, + "additionalProp2": { + "RoleId": 1 + }, + "additionalProp3": { + "RoleId": 1 + } + }, + "UserTrusted": true, + "agent": { + "version": "1.0.0" + }, + "edge": { + "CommandInterval": 60, + "PingInterval": 60, + "SnapshotInterval": 60, + "asyncMode": true + }, + "lastCheckInDate": 0, + "queryDate": 0, + "securitySettings": { + "allowBindMountsForRegularUsers": false, + "allowContainerCapabilitiesForRegularUsers": true, + "allowDeviceMappingForRegularUsers": true, + "allowHostNamespaceForRegularUsers": true, + "allowPrivilegedModeForRegularUsers": false, + "allowStackManagementForRegularUsers": true, + "allowSysctlSettingForRegularUsers": true, + "allowVolumeBrowserForRegularUsers": true, + "enableHostManagementFeatures": true + } + } +] diff --git a/tests/components/portainer/snapshots/test_binary_sensor.ambr b/tests/components/portainer/snapshots/test_binary_sensor.ambr new file mode 100644 index 00000000000000..922b4d6cddf8d3 --- /dev/null +++ b/tests/components/portainer/snapshots/test_binary_sensor.ambr @@ -0,0 +1,295 @@ +# serializer version: 1 +# name: test_all_entities[binary_sensor.focused_einstein_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.focused_einstein_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'portainer_test_entry_123_dd19facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.focused_einstein_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'focused_einstein Status', + }), + 'context': , + 'entity_id': 'binary_sensor.focused_einstein_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.funny_chatelet_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.funny_chatelet_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'portainer_test_entry_123_aa86eacfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.funny_chatelet_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'funny_chatelet Status', + }), + 'context': , + 'entity_id': 'binary_sensor.funny_chatelet_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.my_environment_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.my_environment_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'portainer_test_entry_123_1_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.my_environment_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'my-environment Status', + }), + 'context': , + 'entity_id': 'binary_sensor.my_environment_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.practical_morse_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.practical_morse_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'portainer_test_entry_123_ee20facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.practical_morse_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'practical_morse Status', + }), + 'context': , + 'entity_id': 'binary_sensor.practical_morse_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.serene_banach_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.serene_banach_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'portainer_test_entry_123_bb97facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.serene_banach_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'serene_banach Status', + }), + 'context': , + 'entity_id': 'binary_sensor.serene_banach_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- +# name: test_all_entities[binary_sensor.stoic_turing_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'binary_sensor', + 'entity_category': , + 'entity_id': 'binary_sensor.stoic_turing_status', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Status', + 'platform': 'portainer', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'status', + 'unique_id': 'portainer_test_entry_123_cc08facfb3b3ed4cd362c1e88fc89a53908ad05fb3a4103bca3f9b28292d14bf_status', + 'unit_of_measurement': None, + }) +# --- +# name: test_all_entities[binary_sensor.stoic_turing_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'running', + 'friendly_name': 'stoic_turing Status', + }), + 'context': , + 'entity_id': 'binary_sensor.stoic_turing_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'on', + }) +# --- diff --git a/tests/components/portainer/test_binary_sensor.py b/tests/components/portainer/test_binary_sensor.py new file mode 100644 index 00000000000000..6323cbde08dff7 --- /dev/null +++ b/tests/components/portainer/test_binary_sensor.py @@ -0,0 +1,81 @@ +"""Tests for the Portainer binary sensor platform.""" + +from unittest.mock import AsyncMock, patch + +from freezegun.api import FrozenDateTimeFactory +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) +import pytest +from syrupy.assertion import SnapshotAssertion + +from homeassistant.components.portainer.coordinator import DEFAULT_SCAN_INTERVAL +from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import STATE_UNAVAILABLE, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import entity_registry as er +from homeassistant.util import dt as dt_util + +from . import setup_integration + +from tests.common import MockConfigEntry, async_fire_time_changed, snapshot_platform + + +async def test_all_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + entity_registry: er.EntityRegistry, +) -> None: + """Test all entities.""" + with patch( + "homeassistant.components.portainer._PLATFORMS", + [Platform.BINARY_SENSOR], + ): + await setup_integration(hass, mock_config_entry) + await snapshot_platform( + hass, entity_registry, snapshot, mock_config_entry.entry_id + ) + + +@pytest.mark.parametrize( + ("exception"), + [ + PortainerAuthenticationError("bad creds"), + PortainerConnectionError("cannot connect"), + PortainerTimeoutError("timeout"), + ], +) +async def test_refresh_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + freezer: FrozenDateTimeFactory, + exception: Exception, +) -> None: + """Test entities go unavailable after coordinator refresh failures.""" + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_portainer_client.get_endpoints.side_effect = exception + + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.practical_morse_status") + assert state.state == STATE_UNAVAILABLE + + # Reset endpoints; fail on containers fetch + mock_portainer_client.get_endpoints.side_effect = None + mock_portainer_client.get_containers.side_effect = exception + + freezer.tick(DEFAULT_SCAN_INTERVAL) + async_fire_time_changed(hass, dt_util.utcnow()) + await hass.async_block_till_done() + + state = hass.states.get("binary_sensor.practical_morse_status") + assert state.state == STATE_UNAVAILABLE diff --git a/tests/components/portainer/test_config_flow.py b/tests/components/portainer/test_config_flow.py new file mode 100644 index 00000000000000..50115398c79b84 --- /dev/null +++ b/tests/components/portainer/test_config_flow.py @@ -0,0 +1,127 @@ +"""Test the Portainer config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) +import pytest + +from homeassistant.components.portainer.const import DOMAIN +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from .conftest import MOCK_TEST_CONFIG + +from tests.common import MockConfigEntry + +MOCK_USER_SETUP = { + CONF_HOST: "https://127.0.0.1:9000/", + CONF_API_KEY: "test_api_key", +} + + +async def test_form( + hass: HomeAssistant, + mock_portainer_client: MagicMock, +) -> None: + """Test we get the form.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "https://127.0.0.1:9000/" + assert result["data"] == MOCK_TEST_CONFIG + + +@pytest.mark.parametrize( + ("exception", "reason"), + [ + ( + PortainerAuthenticationError, + "invalid_auth", + ), + ( + PortainerConnectionError, + "cannot_connect", + ), + ( + PortainerTimeoutError, + "timeout_connect", + ), + ( + Exception("Some other error"), + "unknown", + ), + ], +) +async def test_form_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + exception: Exception, + reason: str, +) -> None: + """Test we handle all exceptions.""" + mock_portainer_client.get_endpoints.side_effect = exception + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {"base": reason} + + mock_portainer_client.get_endpoints.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == "https://127.0.0.1:9000/" + assert result["data"] == MOCK_TEST_CONFIG + + +async def test_duplicate_entry( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_setup_entry: MagicMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test we handle duplicate entries.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input=MOCK_USER_SETUP, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" diff --git a/tests/components/portainer/test_init.py b/tests/components/portainer/test_init.py new file mode 100644 index 00000000000000..8c82208752e6b0 --- /dev/null +++ b/tests/components/portainer/test_init.py @@ -0,0 +1,38 @@ +"""Test the Portainer initial specific behavior.""" + +from unittest.mock import AsyncMock + +from pyportainer.exceptions import ( + PortainerAuthenticationError, + PortainerConnectionError, + PortainerTimeoutError, +) +import pytest + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from . import setup_integration + +from tests.common import MockConfigEntry + + +@pytest.mark.parametrize( + ("exception", "expected_state"), + [ + (PortainerAuthenticationError("bad creds"), ConfigEntryState.SETUP_ERROR), + (PortainerConnectionError("cannot connect"), ConfigEntryState.SETUP_RETRY), + (PortainerTimeoutError("timeout"), ConfigEntryState.SETUP_RETRY), + ], +) +async def test_setup_exceptions( + hass: HomeAssistant, + mock_portainer_client: AsyncMock, + mock_config_entry: MockConfigEntry, + exception: Exception, + expected_state: ConfigEntryState, +) -> None: + """Test the _async_setup.""" + mock_portainer_client.get_endpoints.side_effect = exception + await setup_integration(hass, mock_config_entry) + assert mock_config_entry.state == expected_state diff --git a/tests/components/rest/test_data.py b/tests/components/rest/test_data.py index 01581c8ac68f81..a7f162e52c3d8f 100644 --- a/tests/components/rest/test_data.py +++ b/tests/components/rest/test_data.py @@ -541,7 +541,7 @@ async def test_rest_data_boolean_params_converted_to_strings( # Check that the request was made with boolean values converted to strings assert len(aioclient_mock.mock_calls) == 1 - method, url, data, headers = aioclient_mock.mock_calls[0] + _method, url, _data, _headers = aioclient_mock.mock_calls[0] # Check that the URL query parameters have boolean values converted to strings assert url.query["boolTrue"] == "true" diff --git a/tests/components/rest/test_sensor.py b/tests/components/rest/test_sensor.py index 7bd84bbcd70d6b..9a89e9624dc7e2 100644 --- a/tests/components/rest/test_sensor.py +++ b/tests/components/rest/test_sensor.py @@ -1164,7 +1164,7 @@ async def test_query_param_json_string_preserved( # Verify the request was made with the JSON string intact assert len(aioclient_mock.mock_calls) == 1 - method, url, data, headers = aioclient_mock.mock_calls[0] + _method, url, _data, _headers = aioclient_mock.mock_calls[0] assert url.query["filter"] == '{"type": "sensor", "id": 123}' assert url.query["normal"] == "value" diff --git a/tests/components/rflink/test_init.py b/tests/components/rflink/test_init.py index 8f2b3961242b36..736ad4c73cf1db 100644 --- a/tests/components/rflink/test_init.py +++ b/tests/components/rflink/test_init.py @@ -547,7 +547,7 @@ async def test_unique_id( } # setup mocking rflink module - event_callback, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) + _event_callback, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) humidity_entry = entity_registry.async_get("sensor.humidity_device") assert humidity_entry @@ -569,7 +569,7 @@ async def test_enable_debug_logs( config = {DOMAIN: {CONF_HOST: "10.10.0.1", CONF_PORT: 1234}} # setup mocking rflink module - _, mock_create, _, _ = await mock_rflink(hass, config, domain, monkeypatch) + _, _mock_create, _, _ = await mock_rflink(hass, config, domain, monkeypatch) logging.getLogger("rflink").setLevel(logging.DEBUG) hass.bus.async_fire(EVENT_LOGGING_CHANGED) diff --git a/tests/components/rflink/test_sensor.py b/tests/components/rflink/test_sensor.py index 278dd45a114ec3..2f0164a55f9a1e 100644 --- a/tests/components/rflink/test_sensor.py +++ b/tests/components/rflink/test_sensor.py @@ -287,7 +287,7 @@ async def test_sensor_attributes( } # setup mocking rflink module - event_callback, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) + _event_callback, _, _, _ = await mock_rflink(hass, config, DOMAIN, monkeypatch) # test sensor loaded from config meter_state = hass.states.get("sensor.meter_device") diff --git a/tests/components/script/test_init.py b/tests/components/script/test_init.py index 3b0bff7e82e035..908ec222e13187 100644 --- a/tests/components/script/test_init.py +++ b/tests/components/script/test_init.py @@ -654,14 +654,14 @@ async def test_shared_context(hass: HomeAssistant) -> None: assert event_mock.call_count == 1 assert run_mock.call_count == 1 - args, kwargs = run_mock.call_args + args, _kwargs = run_mock.call_args assert args[0].context == context # Ensure event data has all attributes set assert args[0].data.get(ATTR_NAME) == "test" assert args[0].data.get(ATTR_ENTITY_ID) == "script.test" # Ensure context carries through the event - args, kwargs = event_mock.call_args + args, _kwargs = event_mock.call_args assert args[0].context == context # Ensure the script state shares the same context diff --git a/tests/components/sensor/test_recorder.py b/tests/components/sensor/test_recorder.py index 09f2480891ee4c..645c4754a7bd86 100644 --- a/tests/components/sensor/test_recorder.py +++ b/tests/components/sensor/test_recorder.py @@ -1967,7 +1967,7 @@ async def test_compile_hourly_sum_statistics_total_no_reset( } seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] with freeze_time(period0) as freezer: - four, eight, states = await async_record_meter_states( + _four, eight, states = await async_record_meter_states( hass, freezer, period0, "sensor.test1", attributes, seq ) await async_wait_recording_done(hass) @@ -2081,7 +2081,7 @@ async def test_compile_hourly_sum_statistics_total_increasing( } seq = [10, 15, 20, 10, 30, 40, 50, 60, 70] with freeze_time(period0) as freezer: - four, eight, states = await async_record_meter_states( + _four, eight, states = await async_record_meter_states( hass, freezer, period0, "sensor.test1", attributes, seq ) await async_wait_recording_done(hass) @@ -2195,7 +2195,7 @@ async def test_compile_hourly_sum_statistics_total_increasing_small_dip( } seq = [10, 15, 20, 19, 30, 40, 39, 60, 70] with freeze_time(period0) as freezer: - four, eight, states = await async_record_meter_states( + _four, eight, states = await async_record_meter_states( hass, freezer, period0, "sensor.test1", attributes, seq ) await async_wait_recording_done(hass) diff --git a/tests/components/smtp/test_notify.py b/tests/components/smtp/test_notify.py index 0eb8fda09c5d02..2cc4ae851fc29d 100644 --- a/tests/components/smtp/test_notify.py +++ b/tests/components/smtp/test_notify.py @@ -174,7 +174,7 @@ def test_sending_insecure_files_fails( patch("email.utils.make_msgid", return_value=sample_email), pytest.raises(ServiceValidationError) as exc, ): - result, _ = message.send_message(message_data, data=data) + _result, _ = message.send_message(message_data, data=data) assert exc.value.translation_key == "remote_path_not_allowed" assert exc.value.translation_domain == DOMAIN assert ( diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index d64ee2d7a73405..184ec1a9ae3ae1 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -999,3 +999,60 @@ def make_advertisement( connectable=True, tx_power=-127, ) + +RGBICWW_STRIP_LIGHT_SERVICE_INFO = BluetoothServiceInfoBleak( + name="RGBICWW Strip Light", + manufacturer_data={ + 2409: b'(7/L\x94\xb2\x0c\x9e"\x00\x11:\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb3" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="RGBICWW Strip Light", + manufacturer_data={ + 2409: b'(7/L\x94\xb2\x0c\x9e"\x00\x11:\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb3" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "RGBICWW Strip Light"), + time=0, + connectable=True, + tx_power=-127, +) + + +RGBICWW_FLOOR_LAMP_SERVICE_INFO = BluetoothServiceInfoBleak( + name="RGBICWW Floor Lamp", + manufacturer_data={ + 2409: b'\xdc\x06u\xa6\xfb\xb2y\x9e"\x00\x11\xb8\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb4" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="RGBICWW Floor Lamp", + manufacturer_data={ + 2409: b'\xdc\x06u\xa6\xfb\xb2y\x9e"\x00\x11\xb8\x00', + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00\x00\x00\x00\x10\xd0\xb4" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "RGBICWW Floor Lamp"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_light.py b/tests/components/switchbot/test_light.py index 718d7aecf960c8..706597c6052f0a 100644 --- a/tests/components/switchbot/test_light.py +++ b/tests/components/switchbot/test_light.py @@ -25,6 +25,8 @@ BULB_SERVICE_INFO, CEILING_LIGHT_SERVICE_INFO, FLOOR_LAMP_SERVICE_INFO, + RGBICWW_FLOOR_LAMP_SERVICE_INFO, + RGBICWW_STRIP_LIGHT_SERVICE_INFO, STRIP_LIGHT_3_SERVICE_INFO, WOSTRIP_SERVICE_INFO, ) @@ -343,10 +345,16 @@ async def test_strip_light_services_exception( @pytest.mark.parametrize( - ("sensor_type", "service_info"), + ("sensor_type", "service_info", "dev_cls"), [ - ("strip_light_3", STRIP_LIGHT_3_SERVICE_INFO), - ("floor_lamp", FLOOR_LAMP_SERVICE_INFO), + ("strip_light_3", STRIP_LIGHT_3_SERVICE_INFO, "SwitchbotStripLight3"), + ("floor_lamp", FLOOR_LAMP_SERVICE_INFO, "SwitchbotStripLight3"), + ( + "rgbicww_strip_light", + RGBICWW_STRIP_LIGHT_SERVICE_INFO, + "SwitchbotRgbicLight", + ), + ("rgbicww_floor_lamp", RGBICWW_FLOOR_LAMP_SERVICE_INFO, "SwitchbotRgbicLight"), ], ) @pytest.mark.parametrize(*FLOOR_LAMP_PARAMETERS) @@ -355,6 +363,7 @@ async def test_floor_lamp_services( mock_entry_encrypted_factory: Callable[[str], MockConfigEntry], sensor_type: str, service_info: BluetoothServiceInfoBleak, + dev_cls: str, service: str, service_data: dict, mock_method: str, @@ -370,7 +379,7 @@ async def test_floor_lamp_services( mocked_instance = AsyncMock(return_value=True) with patch.multiple( - "homeassistant.components.switchbot.light.switchbot.SwitchbotStripLight3", + f"homeassistant.components.switchbot.light.switchbot.{dev_cls}", **{mock_method: mocked_instance}, update=AsyncMock(return_value=None), ): diff --git a/tests/components/unifiprotect/test_repairs.py b/tests/components/unifiprotect/test_repairs.py index 2d08630e5205ca..6bd42b5b39ac67 100644 --- a/tests/components/unifiprotect/test_repairs.py +++ b/tests/components/unifiprotect/test_repairs.py @@ -5,7 +5,7 @@ from copy import deepcopy from unittest.mock import AsyncMock -from uiprotect.data import Camera, CloudAccount, ModelType, Version +from uiprotect.data import Camera, CloudAccount, Version from homeassistant.components.unifiprotect.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH @@ -77,6 +77,7 @@ async def test_rtsp_read_only_ignore( user.all_permissions = [] ufp.api.get_camera = AsyncMock(return_value=doorbell) + ufp.api.create_camera_rtsps_streams = AsyncMock(return_value=None) await init_entry(hass, ufp, [doorbell]) await async_process_repairs_platforms(hass) @@ -133,6 +134,7 @@ async def test_rtsp_read_only_fix( new_doorbell = deepcopy(doorbell) new_doorbell.channels[1].is_rtsp_enabled = True ufp.api.get_camera = AsyncMock(return_value=new_doorbell) + ufp.api.create_camera_rtsps_streams = AsyncMock(return_value=None) issue_id = f"rtsp_disabled_{doorbell.id}" await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) @@ -175,8 +177,9 @@ async def test_rtsp_writable_fix( new_doorbell = deepcopy(doorbell) new_doorbell.channels[0].is_rtsp_enabled = True + ufp.api.get_camera = AsyncMock(side_effect=[doorbell, new_doorbell]) - ufp.api.update_device = AsyncMock() + ufp.api.create_camera_rtsps_streams = AsyncMock(return_value=None) issue_id = f"rtsp_disabled_{doorbell.id}" await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) @@ -199,11 +202,7 @@ async def test_rtsp_writable_fix( assert data["type"] == "create_entry" - channels = doorbell.unifi_dict()["channels"] - channels[0]["isRtspEnabled"] = True - ufp.api.update_device.assert_called_with( - ModelType.CAMERA, doorbell.id, {"channels": channels} - ) + ufp.api.create_camera_rtsps_streams.assert_called_with(doorbell.id, "high") async def test_rtsp_writable_fix_when_not_setup( @@ -225,8 +224,9 @@ async def test_rtsp_writable_fix_when_not_setup( new_doorbell = deepcopy(doorbell) new_doorbell.channels[0].is_rtsp_enabled = True + ufp.api.get_camera = AsyncMock(side_effect=[doorbell, new_doorbell]) - ufp.api.update_device = AsyncMock() + ufp.api.create_camera_rtsps_streams = AsyncMock(return_value=None) issue_id = f"rtsp_disabled_{doorbell.id}" await ws_client.send_json({"id": 1, "type": "repairs/list_issues"}) @@ -254,11 +254,7 @@ async def test_rtsp_writable_fix_when_not_setup( assert data["type"] == "create_entry" - channels = doorbell.unifi_dict()["channels"] - channels[0]["isRtspEnabled"] = True - ufp.api.update_device.assert_called_with( - ModelType.CAMERA, doorbell.id, {"channels": channels} - ) + ufp.api.create_camera_rtsps_streams.assert_called_with(doorbell.id, "high") async def test_rtsp_no_fix_if_third_party( diff --git a/tests/components/unifiprotect/test_text.py b/tests/components/unifiprotect/test_text.py index 99f16fcbb7578a..bf9f0502e35a29 100644 --- a/tests/components/unifiprotect/test_text.py +++ b/tests/components/unifiprotect/test_text.py @@ -74,7 +74,7 @@ async def test_text_camera_set( assert_entity_counts(hass, Platform.TEXT, 1, 1) description = CAMERA[0] - unique_id, entity_id = await ids_from_device_description( + _unique_id, entity_id = await ids_from_device_description( hass, Platform.TEXT, doorbell, description ) diff --git a/tests/components/uptimerobot/test_config_flow.py b/tests/components/uptimerobot/test_config_flow.py index c7ae6a5d7721c2..621d9cc27c3865 100644 --- a/tests/components/uptimerobot/test_config_flow.py +++ b/tests/components/uptimerobot/test_config_flow.py @@ -3,7 +3,11 @@ from unittest.mock import patch import pytest -from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException +from pyuptimerobot import ( + UptimeRobotApiResponse, + UptimeRobotAuthenticationException, + UptimeRobotException, +) from homeassistant import config_entries from homeassistant.components.uptimerobot.const import DOMAIN @@ -35,7 +39,7 @@ async def test_user(hass: HomeAssistant) -> None: with ( patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ), patch( @@ -66,7 +70,7 @@ async def test_user_key_read_only(hass: HomeAssistant) -> None: assert result["errors"] is None with patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ): result2 = await hass.config_entries.flow.async_configure( @@ -94,7 +98,7 @@ async def test_exception_thrown(hass: HomeAssistant, exception, error_key) -> No ) with patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", side_effect=exception, ): result2 = await hass.config_entries.flow.async_configure( @@ -113,7 +117,7 @@ async def test_api_error(hass: HomeAssistant, caplog: pytest.LogCaptureFixture) ) with patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ERROR), ): result2 = await hass.config_entries.flow.async_configure( @@ -140,7 +144,7 @@ async def test_user_unique_id_already_exists( with ( patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ), patch( @@ -174,7 +178,7 @@ async def test_reauthentication( with ( patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ), patch( @@ -207,7 +211,7 @@ async def test_reauthentication_failure( with ( patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ERROR), ), patch( @@ -243,7 +247,7 @@ async def test_reauthentication_failure_no_existing_entry( with ( patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), ), patch( @@ -276,7 +280,7 @@ async def test_reauthentication_failure_account_not_matching( with ( patch( - "pyuptimerobot.UptimeRobot.async_get_account_details", + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", return_value=mock_uptimerobot_api_response( key=MockApiResponseKey.ACCOUNT, data={**MOCK_UPTIMEROBOT_ACCOUNT, "user_id": 1234567891}, @@ -296,3 +300,179 @@ async def test_reauthentication_failure_account_not_matching( assert result2["step_id"] == "reauth_confirm" assert result2["type"] is FlowResultType.FORM assert result2["errors"]["base"] == "reauth_failed_matching_account" + + +async def test_reconfigure_successful( + hass: HomeAssistant, +) -> None: + """Test that the entry can be reconfigured.""" + config_entry = MockConfigEntry( + **{**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, "unique_id": None} + ) + config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + assert result["step_id"] == "reconfigure" + + new_key = "u0242ac120003-new" + + with ( + patch( + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), + ), + patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: new_key}, + ) + + assert result2["type"] is FlowResultType.ABORT + assert result2["reason"] == "reconfigure_successful" + + # changed entry + assert config_entry.data[CONF_API_KEY] == new_key + + +async def test_reconfigure_failed( + hass: HomeAssistant, +) -> None: + """Test that the entry reconfigure fails with a wrong key.""" + config_entry = MockConfigEntry( + **{**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, "unique_id": None} + ) + config_entry.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + assert result["step_id"] == "reconfigure" + + wrong_key = "u0242ac120003-wrong" + + with ( + patch( + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", + side_effect=UptimeRobotAuthenticationException, + ), + patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: wrong_key}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"]["base"] == "invalid_api_key" + + new_key = "u0242ac120003-new" + + with ( + patch( + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), + ), + patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={CONF_API_KEY: new_key}, + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + # changed entry + assert config_entry.data[CONF_API_KEY] == new_key + + +async def test_reconfigure_with_key_present( + hass: HomeAssistant, +) -> None: + """Test that the entry reconfigure fails with a key from another entry.""" + config_entry = MockConfigEntry( + **{**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, "unique_id": None} + ) + config_entry.add_to_hass(hass) + + api_key_2 = "u0242ac120003-2" + email_2 = "test2@test.test" + user_id_2 = "abcdefghil" + data2 = { + "domain": DOMAIN, + "title": email_2, + "data": {"platform": DOMAIN, "api_key": api_key_2}, + "unique_id": user_id_2, + "source": config_entries.SOURCE_USER, + } + config_entry_2 = MockConfigEntry(**{**data2, "unique_id": None}) + config_entry_2.add_to_hass(hass) + + result = await config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["errors"] is None + assert result["step_id"] == "reconfigure" + + with ( + patch( + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", + return_value=UptimeRobotApiResponse.from_dict( + { + "stat": "ok", + "email": email_2, + "user_id": user_id_2, + "up_monitors": 1, + } + ), + ), + patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ), + ): + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: api_key_2}, + ) + + assert result2["type"] is FlowResultType.FORM + assert result2["errors"] == {} + assert result2["step_id"] == "reconfigure" + + new_key = "u0242ac120003-new" + + with ( + patch( + "homeassistant.components.uptimerobot.config_flow.UptimeRobot.async_get_account_details", + return_value=mock_uptimerobot_api_response(key=MockApiResponseKey.ACCOUNT), + ), + patch( + "homeassistant.components.uptimerobot.async_setup_entry", + return_value=True, + ), + ): + result3 = await hass.config_entries.flow.async_configure( + result2["flow_id"], + user_input={CONF_API_KEY: new_key}, + ) + + assert result3["type"] is FlowResultType.ABORT + assert result3["reason"] == "reconfigure_successful" + + # changed entry + assert config_entry.data[CONF_API_KEY] == new_key diff --git a/tests/components/whirlpool/test_climate.py b/tests/components/whirlpool/test_climate.py index 6157da04256aa4..e5b7abf098abaf 100644 --- a/tests/components/whirlpool/test_climate.py +++ b/tests/components/whirlpool/test_climate.py @@ -36,7 +36,7 @@ Platform, ) from homeassistant.core import HomeAssistant -from homeassistant.exceptions import ServiceValidationError +from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import entity_registry as er from . import init_integration, snapshot_whirlpool_entities, trigger_attr_callback @@ -243,6 +243,41 @@ async def test_service_calls( getattr(mock_instance, expected_call).assert_called_once_with(*expected_args) +@pytest.mark.parametrize( + ("service", "service_data", "request_method"), + [ + (SERVICE_TURN_OFF, {}, "set_power_on"), + (SERVICE_TURN_ON, {}, "set_power_on"), + (SERVICE_SET_HVAC_MODE, {ATTR_HVAC_MODE: HVACMode.COOL}, "set_mode"), + (SERVICE_SET_HVAC_MODE, {ATTR_HVAC_MODE: HVACMode.OFF}, "set_power_on"), + (SERVICE_SET_TEMPERATURE, {ATTR_TEMPERATURE: 20}, "set_temp"), + (SERVICE_SET_FAN_MODE, {ATTR_FAN_MODE: FAN_AUTO}, "set_fanspeed"), + (SERVICE_SET_SWING_MODE, {ATTR_SWING_MODE: SWING_OFF}, "set_h_louver_swing"), + ], +) +async def test_service_request_failure( + hass: HomeAssistant, + service: str, + service_data: dict, + request_method: str, + multiple_climate_entities: tuple[str, str], + request: pytest.FixtureRequest, +) -> None: + """Test controlling the entity through service calls.""" + await init_integration(hass) + entity_id, mock_fixture = multiple_climate_entities + mock_instance = request.getfixturevalue(mock_fixture) + getattr(mock_instance, request_method).return_value = False + + with pytest.raises(HomeAssistantError): + await hass.services.async_call( + CLIMATE_DOMAIN, + service, + {ATTR_ENTITY_ID: entity_id, **service_data}, + blocking=True, + ) + + @pytest.mark.parametrize( ("service", "service_data"), [ diff --git a/tests/components/yale/test_init.py b/tests/components/yale/test_init.py index c028924199e608..ec43c07f1eefd8 100644 --- a/tests/components/yale/test_init.py +++ b/tests/components/yale/test_init.py @@ -36,7 +36,7 @@ async def test_yale_api_is_failing(hass: HomeAssistant) -> None: """Config entry state is SETUP_RETRY when yale api is failing.""" - config_entry, socketio = await _create_yale_with_devices( + config_entry, _socketio = await _create_yale_with_devices( hass, authenticate_side_effect=YaleApiError( "offline", ClientResponseError(None, None, status=500) @@ -48,7 +48,7 @@ async def test_yale_api_is_failing(hass: HomeAssistant) -> None: async def test_yale_is_offline(hass: HomeAssistant) -> None: """Config entry state is SETUP_RETRY when yale is offline.""" - config_entry, socketio = await _create_yale_with_devices( + config_entry, _socketio = await _create_yale_with_devices( hass, authenticate_side_effect=TimeoutError ) @@ -57,7 +57,7 @@ async def test_yale_is_offline(hass: HomeAssistant) -> None: async def test_yale_late_auth_failure(hass: HomeAssistant) -> None: """Test we can detect a late auth failure.""" - config_entry, socketio = await _create_yale_with_devices( + config_entry, _socketio = await _create_yale_with_devices( hass, authenticate_side_effect=InvalidAuth( "authfailed", ClientResponseError(None, None, status=401) @@ -174,7 +174,7 @@ async def test_load_unload(hass: HomeAssistant) -> None: yale_operative_lock = await _mock_operative_yale_lock_detail(hass) yale_inoperative_lock = await _mock_inoperative_yale_lock_detail(hass) - config_entry, socketio = await _create_yale_with_devices( + config_entry, _socketio = await _create_yale_with_devices( hass, [yale_operative_lock, yale_inoperative_lock] ) @@ -193,7 +193,7 @@ async def test_load_triggers_ble_discovery( yale_lock_with_key = await _mock_lock_with_offline_key(hass) yale_lock_without_key = await _mock_operative_yale_lock_detail(hass) - config_entry, socketio = await _create_yale_with_devices( + config_entry, _socketio = await _create_yale_with_devices( hass, [yale_lock_with_key, yale_lock_without_key] ) await hass.async_block_till_done() @@ -218,7 +218,7 @@ async def test_device_remove_devices( """Test we can only remove a device that no longer exists.""" assert await async_setup_component(hass, "config", {}) yale_operative_lock = await _mock_operative_yale_lock_detail(hass) - config_entry, socketio = await _create_yale_with_devices( + config_entry, _socketio = await _create_yale_with_devices( hass, [yale_operative_lock] ) entity = entity_registry.entities["lock.a6697750d607098bae8d6baa11ef8063_name"] diff --git a/tests/components/zha/test_config_flow.py b/tests/components/zha/test_config_flow.py index 94566be2f87380..ff939180fbb0ec 100644 --- a/tests/components/zha/test_config_flow.py +++ b/tests/components/zha/test_config_flow.py @@ -1149,7 +1149,7 @@ async def test_strategy_no_network_settings( """Test formation strategy when no network settings are present.""" mock_app.load_network_info = MagicMock(side_effect=NetworkNotFormed()) - result, port = await pick_radio(RadioType.ezsp) + result, _port = await pick_radio(RadioType.ezsp) assert ( config_flow.FORMATION_REUSE_SETTINGS not in result["data_schema"].schema["next_step_id"].container @@ -1160,7 +1160,7 @@ async def test_formation_strategy_form_new_network( pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test forming a new network.""" - result, port = await pick_radio(RadioType.ezsp) + result, _port = await pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1180,7 +1180,7 @@ async def test_formation_strategy_form_initial_network( """Test forming a new network, with no previous settings on the radio.""" mock_app.load_network_info = AsyncMock(side_effect=NetworkNotFormed()) - result, port = await pick_radio(RadioType.ezsp) + result, _port = await pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": config_flow.FORMATION_FORM_INITIAL_NETWORK}, @@ -1234,7 +1234,7 @@ async def test_formation_strategy_reuse_settings( pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test reusing existing network settings.""" - result, port = await pick_radio(RadioType.ezsp) + result, _port = await pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1270,7 +1270,7 @@ async def test_formation_strategy_restore_manual_backup_non_ezsp( hass: HomeAssistant, ) -> None: """Test restoring a manual backup on non-EZSP coordinators.""" - result, port = await pick_radio(RadioType.znp) + result, _port = await pick_radio(RadioType.znp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1306,7 +1306,7 @@ async def test_formation_strategy_restore_manual_backup_overwrite_ieee_ezsp( hass: HomeAssistant, ) -> None: """Test restoring a manual backup on EZSP coordinators (overwrite IEEE).""" - result, port = await pick_radio(RadioType.ezsp) + result, _port = await pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1349,7 +1349,7 @@ async def test_formation_strategy_restore_manual_backup_ezsp( hass: HomeAssistant, ) -> None: """Test restoring a manual backup on EZSP coordinators (don't overwrite IEEE).""" - result, port = await pick_radio(RadioType.ezsp) + result, _port = await pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1390,7 +1390,7 @@ async def test_formation_strategy_restore_manual_backup_invalid_upload( pick_radio: RadioPicker, mock_app: AsyncMock, hass: HomeAssistant ) -> None: """Test restoring a manual backup but an invalid file is uploaded.""" - result, port = await pick_radio(RadioType.ezsp) + result, _port = await pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], @@ -1450,7 +1450,7 @@ async def test_formation_strategy_restore_automatic_backup_ezsp( backup = mock_app.backups.backups[1] # pick the second one backup.is_compatible_with = MagicMock(return_value=False) - result, port = await pick_radio(RadioType.ezsp) + result, _port = await pick_radio(RadioType.ezsp) result2 = await hass.config_entries.flow.async_configure( result["flow_id"], user_input={"next_step_id": (config_flow.FORMATION_CHOOSE_AUTOMATIC_BACKUP)}, @@ -1503,7 +1503,7 @@ async def test_formation_strategy_restore_automatic_backup_non_ezsp( backup = mock_app.backups.backups[1] # pick the second one backup.is_compatible_with = MagicMock(return_value=False) - result, port = await pick_radio(RadioType.znp) + result, _port = await pick_radio(RadioType.znp) with patch( "homeassistant.config_entries.ConfigFlow.show_advanced_options", @@ -1556,7 +1556,7 @@ async def test_ezsp_restore_without_settings_change_ieee( with patch.object( mock_app, "load_network_info", MagicMock(side_effect=NetworkNotFormed()) ): - result, port = await pick_radio(RadioType.ezsp) + result, _port = await pick_radio(RadioType.ezsp) # Set the network state, it'll be picked up later after the load "succeeds" mock_app.state.node_info = backup.node_info diff --git a/tests/components/zha/test_init.py b/tests/components/zha/test_init.py index 887284919da2e3..66a01e0acace47 100644 --- a/tests/components/zha/test_init.py +++ b/tests/components/zha/test_init.py @@ -193,9 +193,9 @@ async def test_setup_with_v3_cleaning_uri( async def test_migration_baudrate_and_flow_control( radio_type: str, old_baudrate: int, - old_flow_control: typing.Literal["hardware", "software", None], + old_flow_control: typing.Literal["hardware", "software"] | None, new_baudrate: int, - new_flow_control: typing.Literal["hardware", "software", None], + new_flow_control: typing.Literal["hardware", "software"] | None, hass: HomeAssistant, config_entry: MockConfigEntry, ) -> None: diff --git a/tests/components/zha/test_logbook.py b/tests/components/zha/test_logbook.py index 0b27cd095a9326..6119bbec7699d0 100644 --- a/tests/components/zha/test_logbook.py +++ b/tests/components/zha/test_logbook.py @@ -165,7 +165,7 @@ async def test_zha_logbook_event_device_no_triggers( ) -> None: """Test ZHA logbook events with device and without triggers.""" - zigpy_device, zha_device = mock_devices + _zigpy_device, zha_device = mock_devices ieee_address = str(zha_device.device.ieee) reg_device = device_registry.async_get_device(identifiers={("zha", ieee_address)}) diff --git a/tests/components/zha/test_update.py b/tests/components/zha/test_update.py index 04d190b170c392..6371902a639e9d 100644 --- a/tests/components/zha/test_update.py +++ b/tests/components/zha/test_update.py @@ -336,7 +336,7 @@ async def test_firmware_update_success( async def endpoint_reply(cluster, sequence, data, **kwargs): if cluster == general.Ota.cluster_id: - hdr, cmd = ota_cluster.deserialize(data) + _hdr, cmd = ota_cluster.deserialize(data) if isinstance(cmd, general.Ota.ImageNotifyCommand): zha_device.device.device.packet_received( make_packet( @@ -532,7 +532,7 @@ async def test_firmware_update_raises( async def endpoint_reply(cluster, sequence, data, **kwargs): if cluster == general.Ota.cluster_id: - hdr, cmd = ota_cluster.deserialize(data) + _hdr, cmd = ota_cluster.deserialize(data) if isinstance(cmd, general.Ota.ImageNotifyCommand): zha_device.device.device.packet_received( make_packet( diff --git a/tests/test_config_entries.py b/tests/test_config_entries.py index 7d9509a46fabf2..06b6dfd0cf43aa 100644 --- a/tests/test_config_entries.py +++ b/tests/test_config_entries.py @@ -4622,7 +4622,7 @@ async def async_step_link(self, user_input=None): flow3 = manager.flow.async_init( "comp", context={"source": config_entries.SOURCE_HOMEKIT} ) - result1, result2, result3 = await asyncio.gather(flow1, flow2, flow3) + _result1, result2, _result3 = await asyncio.gather(flow1, flow2, flow3) flows = hass.config_entries.flow.async_progress() assert len(flows) == 1