From 2cda0817b262254355eef18181e2e9e18c1163d2 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 10 Sep 2025 17:31:56 +0200 Subject: [PATCH 01/23] Add illuminance sensor for Shelly Plug US Gen4 (#150681) --- homeassistant/components/shelly/icons.json | 3 +++ homeassistant/components/shelly/sensor.py | 8 ++++++++ homeassistant/components/shelly/strings.json | 7 +++++++ 3 files changed, 18 insertions(+) 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" + } } } }, From ceeeb22040fbbd7d78896a5e8e0e799e91291994 Mon Sep 17 00:00:00 2001 From: Sarah Seidman Date: Wed, 10 Sep 2025 11:38:49 -0400 Subject: [PATCH 02/23] Add integration for Droplet (#149989) Co-authored-by: Norbert Rittel Co-authored-by: Josef Zweck Co-authored-by: Joost Lekkerkerker --- .strict-typing | 1 + CODEOWNERS | 2 + homeassistant/components/droplet/__init__.py | 37 +++ .../components/droplet/config_flow.py | 118 ++++++++ homeassistant/components/droplet/const.py | 11 + .../components/droplet/coordinator.py | 84 ++++++ homeassistant/components/droplet/icons.json | 15 + .../components/droplet/manifest.json | 11 + .../components/droplet/quality_scale.yaml | 72 +++++ homeassistant/components/droplet/sensor.py | 131 +++++++++ homeassistant/components/droplet/strings.json | 46 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + homeassistant/generated/zeroconf.py | 5 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/droplet/__init__.py | 13 + tests/components/droplet/conftest.py | 108 +++++++ .../droplet/snapshots/test_sensor.ambr | 240 ++++++++++++++++ tests/components/droplet/test_config_flow.py | 271 ++++++++++++++++++ tests/components/droplet/test_init.py | 41 +++ tests/components/droplet/test_sensor.py | 46 +++ 23 files changed, 1275 insertions(+) create mode 100644 homeassistant/components/droplet/__init__.py create mode 100644 homeassistant/components/droplet/config_flow.py create mode 100644 homeassistant/components/droplet/const.py create mode 100644 homeassistant/components/droplet/coordinator.py create mode 100644 homeassistant/components/droplet/icons.json create mode 100644 homeassistant/components/droplet/manifest.json create mode 100644 homeassistant/components/droplet/quality_scale.yaml create mode 100644 homeassistant/components/droplet/sensor.py create mode 100644 homeassistant/components/droplet/strings.json create mode 100644 tests/components/droplet/__init__.py create mode 100644 tests/components/droplet/conftest.py create mode 100644 tests/components/droplet/snapshots/test_sensor.ambr create mode 100644 tests/components/droplet/test_config_flow.py create mode 100644 tests/components/droplet/test_init.py create mode 100644 tests/components/droplet/test_sensor.py diff --git a/.strict-typing b/.strict-typing index bf5b90b00917dd..882dec39d4405b 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.* diff --git a/CODEOWNERS b/CODEOWNERS index 6a5e4ea437b2b3..b3e1f6c04baa86 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 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/generated/config_flows.py b/homeassistant/generated/config_flows.py index d636fce1d3cbfa..fdbaf7f0451097 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", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 183c7956275310..63b10e06e48b6e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1441,6 +1441,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", 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..b147bdd3f5a846 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 diff --git a/requirements_all.txt b/requirements_all.txt index 095220e8f6ab52..bb93e3e7c688be 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1d1fc01b5b6667..e38058831e330e 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 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" From 6f00f8a920c432cd89c41fd1cbfe3242015cf6f7 Mon Sep 17 00:00:00 2001 From: Marc Mueller <30130371+cdce8p@users.noreply.github.com> Date: Wed, 10 Sep 2025 18:09:11 +0200 Subject: [PATCH 03/23] Update feedreader to 6.0.12 (#152054) --- homeassistant/components/feedreader/manifest.json | 2 +- pyproject.toml | 5 +---- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 7 deletions(-) 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/pyproject.toml b/pyproject.toml index 955068cb6a4778..94050674286761 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", diff --git a/requirements_all.txt b/requirements_all.txt index bb93e3e7c688be..738287560117c2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index e38058831e330e..1976e5af8b2264 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -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 From 6a482b1a3e7d73a2703ddfd0fd4d56b6f1b9d4ec Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 10 Sep 2025 19:20:40 +0200 Subject: [PATCH 04/23] Remove stale devices for Alexa Devices (#151909) --- .../components/alexa_devices/coordinator.py | 32 +++++++- .../alexa_devices/quality_scale.yaml | 4 +- tests/components/alexa_devices/conftest.py | 26 +------ tests/components/alexa_devices/const.py | 24 ++++++ .../alexa_devices/test_coordinator.py | 73 +++++++++++++++++++ 5 files changed, 132 insertions(+), 27 deletions(-) create mode 100644 tests/components/alexa_devices/test_coordinator.py 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/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) From ccef31a37a13fb606da97f7be28f81b0c934523e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 10 Sep 2025 18:47:31 +0100 Subject: [PATCH 05/23] Set PARALLEL_UPDATES in Whirlpool integration (#152065) --- homeassistant/components/whirlpool/binary_sensor.py | 1 + homeassistant/components/whirlpool/climate.py | 2 ++ homeassistant/components/whirlpool/sensor.py | 1 + 3 files changed, 4 insertions(+) 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..c8f7ee10f99afd 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, 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 = { From 4592d6370af036f77f91fdc7549b6596f2e0d2da Mon Sep 17 00:00:00 2001 From: "J. Nick Koston" Date: Wed, 10 Sep 2025 13:39:52 -0500 Subject: [PATCH 06/23] Bump PySwitchBot to 0.70.0 (#152072) --- homeassistant/components/switchbot/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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/requirements_all.txt b/requirements_all.txt index 738287560117c2..3a004084b2b02d 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 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 1976e5af8b2264..6f51dd16ea1b68 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 From 83f3b3e3ebe8af80c7d41e991255a0c115bd6203 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 10 Sep 2025 20:48:31 +0200 Subject: [PATCH 07/23] Bump ruff to 0.13.0 (#152067) --- .pre-commit-config.yaml | 2 +- pyproject.toml | 9 ++++++--- requirements_test_pre_commit.txt | 2 +- script/hassfest/docker/Dockerfile | 2 +- 4 files changed, 9 insertions(+), 6 deletions(-) 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/pyproject.toml b/pyproject.toml index 94050674286761..836aee6e8a7dae 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -637,7 +637,7 @@ exclude_lines = [ ] [tool.ruff] -required-version = ">=0.12.1" +required-version = ">=0.13.0" [tool.ruff.lint] select = [ @@ -779,8 +779,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 @@ -798,6 +797,10 @@ ignore = [ # Disabled because ruff does not understand type of __all__ generated by a function "PLE0605", + + "PYI059", + "PYI061", + "FURB116" ] [tool.ruff.lint.flake8-import-conventions.extend-aliases] 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 \ From 7d471f9624be456c90d35dc3cacbf231acac4bac Mon Sep 17 00:00:00 2001 From: Retha Runolfsson <137745329+zerzhang@users.noreply.github.com> Date: Thu, 11 Sep 2025 02:52:47 +0800 Subject: [PATCH 08/23] Add rgbicww light for switchbot integration (#151129) --- .../components/switchbot/__init__.py | 4 ++ homeassistant/components/switchbot/const.py | 8 +++ homeassistant/components/switchbot/icons.json | 25 +++++++- .../components/switchbot/strings.json | 25 +++++++- tests/components/switchbot/__init__.py | 57 +++++++++++++++++++ tests/components/switchbot/test_light.py | 17 ++++-- 6 files changed, 130 insertions(+), 6 deletions(-) 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/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/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), ): From b496637bddb2e6fb4a60ef9f1429b68263013f4d Mon Sep 17 00:00:00 2001 From: Erwin Douna Date: Wed, 10 Sep 2025 21:01:41 +0200 Subject: [PATCH 09/23] Add Portainer integration (#142875) Co-authored-by: Manu <4445816+tr4nt0r@users.noreply.github.com> Co-authored-by: Joost Lekkerkerker --- .strict-typing | 1 + CODEOWNERS | 2 + .../components/portainer/__init__.py | 40 +++ .../components/portainer/binary_sensor.py | 146 +++++++++ .../components/portainer/config_flow.py | 95 ++++++ homeassistant/components/portainer/const.py | 4 + .../components/portainer/coordinator.py | 137 ++++++++ homeassistant/components/portainer/entity.py | 73 +++++ .../components/portainer/manifest.json | 10 + .../components/portainer/quality_scale.yaml | 80 +++++ .../components/portainer/strings.json | 49 +++ homeassistant/generated/config_flows.py | 1 + homeassistant/generated/integrations.json | 6 + mypy.ini | 10 + requirements_all.txt | 3 + requirements_test_all.txt | 3 + tests/components/portainer/__init__.py | 13 + tests/components/portainer/conftest.py | 63 ++++ .../portainer/fixtures/containers.json | 166 ++++++++++ .../portainer/fixtures/endpoints.json | 195 ++++++++++++ .../snapshots/test_binary_sensor.ambr | 295 ++++++++++++++++++ .../portainer/test_binary_sensor.py | 81 +++++ .../components/portainer/test_config_flow.py | 127 ++++++++ tests/components/portainer/test_init.py | 38 +++ 24 files changed, 1638 insertions(+) create mode 100644 homeassistant/components/portainer/__init__.py create mode 100644 homeassistant/components/portainer/binary_sensor.py create mode 100644 homeassistant/components/portainer/config_flow.py create mode 100644 homeassistant/components/portainer/const.py create mode 100644 homeassistant/components/portainer/coordinator.py create mode 100644 homeassistant/components/portainer/entity.py create mode 100644 homeassistant/components/portainer/manifest.json create mode 100644 homeassistant/components/portainer/quality_scale.yaml create mode 100644 homeassistant/components/portainer/strings.json create mode 100644 tests/components/portainer/__init__.py create mode 100644 tests/components/portainer/conftest.py create mode 100644 tests/components/portainer/fixtures/containers.json create mode 100644 tests/components/portainer/fixtures/endpoints.json create mode 100644 tests/components/portainer/snapshots/test_binary_sensor.ambr create mode 100644 tests/components/portainer/test_binary_sensor.py create mode 100644 tests/components/portainer/test_config_flow.py create mode 100644 tests/components/portainer/test_init.py diff --git a/.strict-typing b/.strict-typing index 882dec39d4405b..78203703d1ac03 100644 --- a/.strict-typing +++ b/.strict-typing @@ -402,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 b3e1f6c04baa86..c4ce561fdb62dc 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1191,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/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/generated/config_flows.py b/homeassistant/generated/config_flows.py index fdbaf7f0451097..99cbbbde73a9f9 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -495,6 +495,7 @@ "point", "pooldose", "poolsense", + "portainer", "powerfox", "powerwall", "private_ble_device", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 63b10e06e48b6e..84a3eb94693f86 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -5072,6 +5072,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/mypy.ini b/mypy.ini index b147bdd3f5a846..5b1f9d3eb0a924 100644 --- a/mypy.ini +++ b/mypy.ini @@ -3776,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/requirements_all.txt b/requirements_all.txt index 3a004084b2b02d..19954a75795b0a 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -2266,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 6f51dd16ea1b68..79986cbe3e2403 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1890,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/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 From c4649fc0686472c2d85b02fdb2d9697477a9e6b0 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 10 Sep 2025 21:09:44 +0200 Subject: [PATCH 10/23] Avoid cleanup/recreate of device_trackers not linked to a device for Vodafone Station (#151904) --- .../vodafone_station/coordinator.py | 21 ++++++++++--------- 1 file changed, 11 insertions(+), 10 deletions(-) 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( From 0a35fd0ea4af0de063ca7e430a9ae7e8334e91f0 Mon Sep 17 00:00:00 2001 From: Simone Chemelli Date: Wed, 10 Sep 2025 21:12:57 +0200 Subject: [PATCH 11/23] Add key reconfigure to UptimeRobot config flow (#151562) --- .../components/uptimerobot/config_flow.py | 27 +++ .../components/uptimerobot/quality_scale.yaml | 4 +- .../components/uptimerobot/strings.json | 9 + .../uptimerobot/test_config_flow.py | 200 +++++++++++++++++- 4 files changed, 227 insertions(+), 13 deletions(-) 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/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 From 908263713379cd55ff3361528aeea17d4492521f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ab=C3=ADlio=20Costa?= Date: Wed, 10 Sep 2025 20:13:12 +0100 Subject: [PATCH 12/23] Raise on service calls in Whirlpool (#152057) Co-authored-by: Joost Lekkerkerker Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/whirlpool/climate.py | 32 +++++++++++----- homeassistant/components/whirlpool/entity.py | 10 +++++ .../components/whirlpool/strings.json | 3 ++ tests/components/whirlpool/test_climate.py | 37 ++++++++++++++++++- 4 files changed, 71 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/whirlpool/climate.py b/homeassistant/components/whirlpool/climate.py index c8f7ee10f99afd..af406f359fdefc 100644 --- a/homeassistant/components/whirlpool/climate.py +++ b/homeassistant/components/whirlpool/climate.py @@ -94,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: @@ -108,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: @@ -122,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: @@ -139,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: @@ -147,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/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/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"), [ From 46c38f185cae0f6f60e4a9bb7e0e54f0952312cf Mon Sep 17 00:00:00 2001 From: Maciej Bieniek Date: Wed, 10 Sep 2025 21:15:48 +0200 Subject: [PATCH 13/23] Adapt AccuWeather to new paid API plans (#152056) Co-authored-by: Joostlek --- homeassistant/components/accuweather/config_flow.py | 1 + homeassistant/components/accuweather/const.py | 2 +- homeassistant/components/accuweather/manifest.json | 3 +-- homeassistant/components/accuweather/strings.json | 3 +++ homeassistant/generated/integrations.json | 3 +-- tests/components/accuweather/test_config_flow.py | 4 ++-- 6 files changed, 9 insertions(+), 7 deletions(-) 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/generated/integrations.json b/homeassistant/generated/integrations.json index 84a3eb94693f86..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", 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( From e3c0cfd1e2f56f07db412a4f9e3a7364e3e29f70 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 10 Sep 2025 21:16:09 +0200 Subject: [PATCH 14/23] Enable RUF059 and fix violations (#152071) --- homeassistant/components/heos/media_player.py | 4 +-- .../husqvarna_automower_ble/config_flow.py | 2 +- homeassistant/components/lightwave/sensor.py | 4 +-- .../components/lovelace/dashboard.py | 4 +-- homeassistant/components/reolink/__init__.py | 6 ++-- homeassistant/components/reolink/services.py | 2 +- homeassistant/components/reolink/views.py | 2 +- homeassistant/components/sma/config_flow.py | 4 +-- .../components/teslemetry/services.py | 6 ++-- pyproject.toml | 1 + tests/components/alexa/test_capabilities.py | 2 +- tests/components/alexa/test_smart_home.py | 4 +-- tests/components/derivative/test_sensor.py | 2 +- tests/components/dsmr/test_config_flow.py | 18 +++++----- tests/components/dsmr/test_diagnostics.py | 2 +- tests/components/dsmr/test_mbus_migration.py | 8 ++--- tests/components/dsmr/test_sensor.py | 36 +++++++++---------- tests/components/evohome/test_storage.py | 2 +- tests/components/google_wifi/test_sensor.py | 16 ++++----- .../specific_devices/test_connectsense.py | 2 +- .../specific_devices/test_ecobee3.py | 2 +- .../homematicip_cloud/test_binary_sensor.py | 2 +- .../homematicip_cloud/test_climate.py | 2 +- .../homematicip_cloud/test_device.py | 2 +- .../homematicip_cloud/test_light.py | 2 +- .../homematicip_cloud/test_sensor.py | 26 +++++++------- tests/components/insteon/test_api_config.py | 2 +- .../components/insteon/test_api_properties.py | 2 +- tests/components/insteon/test_config_flow.py | 2 +- .../intellifire/test_config_flow.py | 4 +-- tests/components/nest/test_api.py | 4 +-- .../components/nibe_heatpump/test_climate.py | 2 +- tests/components/onvif/test_button.py | 2 +- tests/components/rest/test_data.py | 2 +- tests/components/rest/test_sensor.py | 2 +- tests/components/rflink/test_init.py | 4 +-- tests/components/rflink/test_sensor.py | 2 +- tests/components/script/test_init.py | 4 +-- tests/components/sensor/test_recorder.py | 6 ++-- tests/components/smtp/test_notify.py | 2 +- tests/components/unifiprotect/test_text.py | 2 +- tests/components/yale/test_init.py | 12 +++---- tests/components/zha/test_config_flow.py | 22 ++++++------ tests/components/zha/test_logbook.py | 2 +- tests/components/zha/test_update.py | 4 +-- tests/test_config_entries.py | 2 +- 46 files changed, 125 insertions(+), 122 deletions(-) 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/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/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/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/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/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/pyproject.toml b/pyproject.toml index 836aee6e8a7dae..ef1e55cf307d90 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -707,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} 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/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/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/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/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/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/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_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 From e73c67002503853080655a541ac195d115b1e4bc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 10 Sep 2025 21:17:59 +0200 Subject: [PATCH 15/23] Enable PYI061 and fix violations (#152070) --- homeassistant/components/energy/sensor.py | 2 +- pyproject.toml | 1 - tests/components/openuv/test_binary_sensor.py | 3 +-- tests/components/openuv/test_sensor.py | 3 +-- tests/components/zha/test_init.py | 4 ++-- 5 files changed, 5 insertions(+), 8 deletions(-) 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/pyproject.toml b/pyproject.toml index ef1e55cf307d90..b4bf470f9746f9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -800,7 +800,6 @@ ignore = [ "PLE0605", "PYI059", - "PYI061", "FURB116" ] diff --git a/tests/components/openuv/test_binary_sensor.py b/tests/components/openuv/test_binary_sensor.py index d6025b9ed20429..83122f741ad9fc 100644 --- a/tests/components/openuv/test_binary_sensor.py +++ b/tests/components/openuv/test_binary_sensor.py @@ -1,6 +1,5 @@ """Test OpenUV binary sensors.""" -from typing import Literal from unittest.mock import patch from syrupy.assertion import SnapshotAssertion @@ -15,7 +14,7 @@ async def test_binary_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/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/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: From 885256299f7ddd836201779a64c50ce6075367bc Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 10 Sep 2025 21:52:21 +0200 Subject: [PATCH 16/23] Enable PYI059 and fix violations (#152069) --- homeassistant/components/deconz/sensor.py | 2 +- homeassistant/components/lidarr/coordinator.py | 2 +- homeassistant/components/qbus/entity.py | 2 +- homeassistant/components/radarr/coordinator.py | 2 +- pyproject.toml | 1 - 5 files changed, 4 insertions(+), 5 deletions(-) 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/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/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/pyproject.toml b/pyproject.toml index b4bf470f9746f9..eefeb59d5a3c94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -799,7 +799,6 @@ ignore = [ # Disabled because ruff does not understand type of __all__ generated by a function "PLE0605", - "PYI059", "FURB116" ] From 214925e10a37a544891cec7ca32754d2422f6b3d Mon Sep 17 00:00:00 2001 From: Raphael Hehl <7577984+RaHehl@users.noreply.github.com> Date: Wed, 10 Sep 2025 22:13:14 +0200 Subject: [PATCH 17/23] Refactor unifiprotect RTSP repair flow to use publicapi create_rtsps_streams method (#149542) --- .../components/unifiprotect/repairs.py | 13 ++--------- tests/components/unifiprotect/test_repairs.py | 22 ++++++++----------- 2 files changed, 11 insertions(+), 24 deletions(-) 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/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( From 4c548830b446c89abbcf3628ec3e81a2fe373421 Mon Sep 17 00:00:00 2001 From: Paul Bottein Date: Wed, 10 Sep 2025 22:15:23 +0200 Subject: [PATCH 18/23] Use state selector for select option service (#148960) --- homeassistant/components/input_select/services.yaml | 5 ++++- homeassistant/components/select/services.yaml | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) 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/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: From 720ecde56889f84aa30b08e0966a0a7985414a22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ludovic=20BOU=C3=89?= Date: Wed, 10 Sep 2025 22:16:43 +0200 Subject: [PATCH 19/23] Support for Matter MountedDimmableLoadControl device type (#151330) Support for Matter MountedDimmableLoadControl device type: Matter MountedDimmableLoadControl device was wrongly reco gnized as Switch entity. This PR fix thte behavior and makes it recognized as Light entity as expected. There is no Matter MountedDimmableLoadControl device in the market yet so it doesn't really breaks anything. --- homeassistant/components/matter/light.py | 1 + .../matter/snapshots/test_light.ambr | 56 +++++++++++++++++++ .../matter/snapshots/test_switch.ambr | 49 ---------------- 3 files changed, 57 insertions(+), 49 deletions(-) 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/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({ From 002493c3e168e7cd22eb051d2788c8c3f3188d37 Mon Sep 17 00:00:00 2001 From: Whitney Young Date: Wed, 10 Sep 2025 13:18:50 -0700 Subject: [PATCH 20/23] Openuv protection window internal update (#146409) --- homeassistant/components/openuv/__init__.py | 14 ++- .../components/openuv/binary_sensor.py | 41 ++++----- .../components/openuv/coordinator.py | 92 ++++++++++++++++++- .../openuv/snapshots/test_binary_sensor.ambr | 51 ++++++++++ tests/components/openuv/test_binary_sensor.py | 86 ++++++++++++++++- tests/components/openuv/test_diagnostics.py | 5 +- 6 files changed, 256 insertions(+), 33 deletions(-) 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/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 83122f741ad9fc..1885966c4f9b54 100644 --- a/tests/components/openuv/test_binary_sensor.py +++ b/tests/components/openuv/test_binary_sensor.py @@ -2,13 +2,19 @@ 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( @@ -24,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": { From 4ad664a652d5f4eb5832114c96e7a2613a75da5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Ville=20Skytt=C3=A4?= Date: Wed, 10 Sep 2025 23:32:21 +0300 Subject: [PATCH 21/23] Add huawei_lte quality scale YAML (#143347) Co-authored-by: Joost Lekkerkerker --- .../components/huawei_lte/quality_scale.yaml | 88 +++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 88 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/huawei_lte/quality_scale.yaml 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/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 62074b9a1cf108..715bafd54ac688 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", From d71b1246cf084dfcbf47bd60e86478670d0cae91 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 10 Sep 2025 22:57:46 +0200 Subject: [PATCH 22/23] Add DHCP discovery to Aladdin Connect (#151532) --- .../components/aladdin_connect/manifest.json | 5 + .../components/aladdin_connect/strings.json | 3 + homeassistant/generated/dhcp.py | 4 + .../aladdin_connect/test_config_flow.py | 97 +++++++++++++++++++ 4 files changed, 109 insertions(+) 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/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/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, From 8367930f4237303cf149c1033d468aa3918af4e6 Mon Sep 17 00:00:00 2001 From: Tsvi Mostovicz Date: Thu, 11 Sep 2025 00:25:16 +0300 Subject: [PATCH 23/23] Jewish Calendar quality scale (#143763) --- .../jewish_calendar/quality_scale.yaml | 100 ++++++++++++++++++ script/hassfest/quality_scale.py | 1 - 2 files changed, 100 insertions(+), 1 deletion(-) create mode 100644 homeassistant/components/jewish_calendar/quality_scale.yaml 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/script/hassfest/quality_scale.py b/script/hassfest/quality_scale.py index 715bafd54ac688..2c34cf36c88cf2 100644 --- a/script/hassfest/quality_scale.py +++ b/script/hassfest/quality_scale.py @@ -529,7 +529,6 @@ class Rule: "itunes", "izone", "jellyfin", - "jewish_calendar", "joaoapps_join", "juicenet", "justnimbus",