diff --git a/.github/workflows/builder.yml b/.github/workflows/builder.yml index 55efee86bfb30c..11c656bb5792be 100644 --- a/.github/workflows/builder.yml +++ b/.github/workflows/builder.yml @@ -69,7 +69,7 @@ jobs: run: find ./homeassistant/components/*/translations -name "*.json" | tar zcvf translations.tar.gz -T - - name: Upload translations - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: translations path: translations.tar.gz @@ -175,7 +175,7 @@ jobs: sed -i "s|pykrakenapi|# pykrakenapi|g" requirements_all.txt - name: Download translations - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: translations @@ -464,7 +464,7 @@ jobs: python-version: ${{ env.DEFAULT_PYTHON }} - name: Download translations - uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: translations diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index dbf52fcdd777af..3ce3a396434e34 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -535,7 +535,7 @@ jobs: python --version uv pip freeze >> pip_freeze.txt - name: Upload pip_freeze artifact - uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: pip-freeze-${{ matrix.python-version }} path: pip_freeze.txt @@ -867,7 +867,7 @@ jobs: run: | echo "::add-matcher::.github/workflows/matchers/pytest-slow.json" - name: Download pytest_buckets - uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: pytest_buckets - &compile-english-translations diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml index 4ec4edb2be0345..91c6c5c1783ed3 100644 --- a/.github/workflows/codeql.yml +++ b/.github/workflows/codeql.yml @@ -24,11 +24,11 @@ jobs: uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 # v5.0.0 - name: Initialize CodeQL - uses: github/codeql-action/init@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 + uses: github/codeql-action/init@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 with: languages: python - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@16140ae1a102900babc80a33c44059580f687047 # v4.30.9 + uses: github/codeql-action/analyze@4e94bd11f71e507f7f87df81788dff88d1dacbfb # v4.31.0 with: category: "/language:python" diff --git a/.github/workflows/wheels.yml b/.github/workflows/wheels.yml index b176633526ca90..91539fa3bb0779 100644 --- a/.github/workflows/wheels.yml +++ b/.github/workflows/wheels.yml @@ -92,7 +92,7 @@ jobs: ) > build_constraints.txt - name: Upload env_file - uses: &actions-upload-artifact actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + uses: &actions-upload-artifact actions/upload-artifact@330a01c490aca151604b8cf639adc76d48f6c5d4 # v5.0.0 with: name: env_file path: ./.env_file @@ -150,7 +150,7 @@ jobs: - &download-env-file name: Download env_file - uses: &actions-download-artifact actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 # v5.0.0 + uses: &actions-download-artifact actions/download-artifact@018cc2cf5baa6db3ef3c5f8a56943fffe632ef53 # v6.0.0 with: name: env_file diff --git a/CODEOWNERS b/CODEOWNERS index 696b7a0f2f0249..218d4a47defe39 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1543,6 +1543,8 @@ build.json @home-assistant/supervisor /tests/components/suez_water/ @ooii @jb101010-2 /homeassistant/components/sun/ @home-assistant/core /tests/components/sun/ @home-assistant/core +/homeassistant/components/sunricher_dali_center/ @niracler +/tests/components/sunricher_dali_center/ @niracler /homeassistant/components/supla/ @mwegrzynek /homeassistant/components/surepetcare/ @benleb @danielhiversen /tests/components/surepetcare/ @benleb @danielhiversen diff --git a/homeassistant/components/axis/config_flow.py b/homeassistant/components/axis/config_flow.py index 388e360040eede..0596eb655034b6 100644 --- a/homeassistant/components/axis/config_flow.py +++ b/homeassistant/components/axis/config_flow.py @@ -109,12 +109,12 @@ async def async_step_user( if self.source == SOURCE_REAUTH: self._abort_if_unique_id_mismatch() - return self.async_update_reload_and_abort( + return self.async_update_and_abort( self._get_reauth_entry(), data_updates=config ) if self.source == SOURCE_RECONFIGURE: self._abort_if_unique_id_mismatch() - return self.async_update_reload_and_abort( + return self.async_update_and_abort( self._get_reconfigure_entry(), data_updates=config ) self._abort_if_unique_id_configured() @@ -248,7 +248,7 @@ async def _process_discovered_device( await self.async_set_unique_id(discovery_info[CONF_MAC]) self._abort_if_unique_id_configured( - updates={CONF_HOST: discovery_info[CONF_HOST]} + updates={CONF_HOST: discovery_info[CONF_HOST]}, reload_on_update=False ) self.context.update( diff --git a/homeassistant/components/cert_expiry/config_flow.py b/homeassistant/components/cert_expiry/config_flow.py index c351435a73e528..f6dafa18a2528b 100644 --- a/homeassistant/components/cert_expiry/config_flow.py +++ b/homeassistant/components/cert_expiry/config_flow.py @@ -14,6 +14,7 @@ from .const import DEFAULT_PORT, DOMAIN from .errors import ( ConnectionRefused, + ConnectionReset, ConnectionTimeout, ResolveFailed, ValidationFailure, @@ -49,6 +50,8 @@ async def _test_connection( self._errors[CONF_HOST] = "connection_timeout" except ConnectionRefused: self._errors[CONF_HOST] = "connection_refused" + except ConnectionReset: + self._errors[CONF_HOST] = "connection_reset" except ValidationFailure: return True else: diff --git a/homeassistant/components/cert_expiry/errors.py b/homeassistant/components/cert_expiry/errors.py index 25a1a9a835827d..cce37a99b1aa7f 100644 --- a/homeassistant/components/cert_expiry/errors.py +++ b/homeassistant/components/cert_expiry/errors.py @@ -25,3 +25,7 @@ class ConnectionTimeout(TemporaryFailure): class ConnectionRefused(TemporaryFailure): """Network connection refused.""" + + +class ConnectionReset(TemporaryFailure): + """Network connection reset.""" diff --git a/homeassistant/components/cert_expiry/helper.py b/homeassistant/components/cert_expiry/helper.py index b35687dc933931..eda6937c5dd285 100644 --- a/homeassistant/components/cert_expiry/helper.py +++ b/homeassistant/components/cert_expiry/helper.py @@ -13,6 +13,7 @@ from .const import TIMEOUT from .errors import ( ConnectionRefused, + ConnectionReset, ConnectionTimeout, ResolveFailed, ValidationFailure, @@ -58,6 +59,8 @@ async def get_cert_expiry_timestamp( raise ConnectionRefused( f"Connection refused by server: {hostname}:{port}" ) from err + except ConnectionResetError as err: + raise ConnectionReset(f"Connection reset by server: {hostname}:{port}") from err except ssl.CertificateError as err: raise ValidationFailure(err.verify_message) from err except ssl.SSLError as err: diff --git a/homeassistant/components/cert_expiry/strings.json b/homeassistant/components/cert_expiry/strings.json index b8c7ffe037f43f..b8e758b30234ca 100644 --- a/homeassistant/components/cert_expiry/strings.json +++ b/homeassistant/components/cert_expiry/strings.json @@ -14,7 +14,8 @@ "error": { "resolve_failed": "This host cannot be resolved", "connection_timeout": "Timeout when connecting to this host", - "connection_refused": "Connection refused when connecting to host" + "connection_refused": "Connection refused when connecting to host", + "connection_reset": "Connection reset when connecting to host" }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", diff --git a/homeassistant/components/deconz/config_flow.py b/homeassistant/components/deconz/config_flow.py index 41e45d53c76c96..22ac5afb4ecc05 100644 --- a/homeassistant/components/deconz/config_flow.py +++ b/homeassistant/components/deconz/config_flow.py @@ -184,7 +184,8 @@ async def _create_entry(self) -> ConfigFlowResult: CONF_HOST: self.host, CONF_PORT: self.port, CONF_API_KEY: self.api_key, - } + }, + reload_on_update=False, ) except TimeoutError: @@ -231,7 +232,8 @@ async def async_step_ssdp( updates={ CONF_HOST: self.host, CONF_PORT: self.port, - } + }, + reload_on_update=False, ) self.context.update( @@ -265,7 +267,8 @@ async def async_step_hassio( CONF_HOST: self.host, CONF_PORT: self.port, CONF_API_KEY: self.api_key, - } + }, + reload_on_update=False, ) self.context["configuration_url"] = HASSIO_CONFIGURATION_URL diff --git a/homeassistant/components/homematicip_cloud/manifest.json b/homeassistant/components/homematicip_cloud/manifest.json index 1470d1147ab728..17a75c33222c69 100644 --- a/homeassistant/components/homematicip_cloud/manifest.json +++ b/homeassistant/components/homematicip_cloud/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/homematicip_cloud", "iot_class": "cloud_push", "loggers": ["homematicip"], - "requirements": ["homematicip==2.3.0"] + "requirements": ["homematicip==2.3.1"] } diff --git a/homeassistant/components/homewizard/__init__.py b/homeassistant/components/homewizard/__init__.py index 6c9530db72c4be..fdc571e1dca43c 100644 --- a/homeassistant/components/homewizard/__init__.py +++ b/homeassistant/components/homewizard/__init__.py @@ -11,6 +11,7 @@ from homeassistant.const import CONF_IP_ADDRESS, CONF_TOKEN from homeassistant.core import HomeAssistant from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -71,6 +72,25 @@ async def async_unload_entry(hass: HomeAssistant, entry: HomeWizardConfigEntry) return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) +def get_main_device( + hass: HomeAssistant, entry: HomeWizardConfigEntry +) -> dr.DeviceEntry | None: + """Helper function to get the main device for the config entry.""" + device_registry = dr.async_get(hass) + device_entries = dr.async_entries_for_config_entry( + device_registry, config_entry_id=entry.entry_id + ) + + if not device_entries: + return None + + # Get first device that is not a sub-device, as this is the main device in HomeWizard + # This is relevant for the P1 Meter which may create sub-devices for external utility meters + return next( + (device for device in device_entries if device.via_device_id is None), None + ) + + async def async_check_v2_support_and_create_issue( hass: HomeAssistant, entry: HomeWizardConfigEntry ) -> None: @@ -79,6 +99,16 @@ async def async_check_v2_support_and_create_issue( if not await has_v2_api(entry.data[CONF_IP_ADDRESS], async_get_clientsession(hass)): return + title = entry.title + + # Try to get the name from the device registry + # This is to make it clearer which device needs reconfiguration, as the config entry title is kept default most of the time + if main_device := get_main_device(hass, entry): + device_name = main_device.name_by_user or main_device.name + + if device_name and entry.title != device_name: + title = f"{entry.title} ({device_name})" + async_create_issue( hass, DOMAIN, @@ -88,7 +118,7 @@ async def async_check_v2_support_and_create_issue( learn_more_url="https://home-assistant.io/integrations/homewizard/#which-button-do-i-need-to-press-to-configure-the-device", translation_key="migrate_to_v2_api", translation_placeholders={ - "title": entry.title, + "title": title, }, severity=IssueSeverity.WARNING, data={"entry_id": entry.entry_id}, diff --git a/homeassistant/components/homewizard/strings.json b/homeassistant/components/homewizard/strings.json index 84594a440f9b0c..84c070fd151e6a 100644 --- a/homeassistant/components/homewizard/strings.json +++ b/homeassistant/components/homewizard/strings.json @@ -177,7 +177,7 @@ }, "issues": { "migrate_to_v2_api": { - "title": "Update authentication method", + "title": "Update the authentication method for {title}", "fix_flow": { "step": { "confirm": { diff --git a/homeassistant/components/matter/__init__.py b/homeassistant/components/matter/__init__.py index e3e30fb704b8e9..ae005ebbf05bd9 100644 --- a/homeassistant/components/matter/__init__.py +++ b/homeassistant/components/matter/__init__.py @@ -20,13 +20,14 @@ from homeassistant.const import CONF_URL, EVENT_HOMEASSISTANT_STOP from homeassistant.core import Event, HomeAssistant, callback from homeassistant.exceptions import ConfigEntryNotReady -from homeassistant.helpers import device_registry as dr +from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.issue_registry import ( IssueSeverity, async_create_issue, async_delete_issue, ) +from homeassistant.helpers.typing import ConfigType from .adapter import MatterAdapter from .addon import get_addon_manager @@ -40,10 +41,13 @@ node_from_ha_device_id, ) from .models import MatterDeviceInfo +from .services import async_setup_services CONNECT_TIMEOUT = 10 LISTEN_READY_TIMEOUT = 30 +CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) + @callback @cache @@ -64,6 +68,12 @@ def get_matter_device_info( ) +async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: + """Set up the Matter integration services.""" + async_setup_services(hass) + return True + + async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: """Set up Matter from a config entry.""" if use_addon := entry.data.get(CONF_USE_ADDON): diff --git a/homeassistant/components/matter/button.py b/homeassistant/components/matter/button.py index 73cbf6a24c9f4a..a8aa524580db03 100644 --- a/homeassistant/components/matter/button.py +++ b/homeassistant/components/matter/button.py @@ -155,4 +155,18 @@ async def async_press(self) -> None: required_attributes=(clusters.SmokeCoAlarm.Attributes.AcceptedCommandList,), value_contains=clusters.SmokeCoAlarm.Commands.SelfTestRequest.command_id, ), + MatterDiscoverySchema( + platform=Platform.BUTTON, + entity_description=MatterButtonEntityDescription( + key="WaterHeaterManagementCancelBoost", + translation_key="cancel_boost", + command=clusters.WaterHeaterManagement.Commands.CancelBoost, + ), + entity_class=MatterCommandButton, + required_attributes=( + clusters.WaterHeaterManagement.Attributes.AcceptedCommandList, + ), + value_contains=clusters.WaterHeaterManagement.Commands.CancelBoost.command_id, + allow_multi=True, # Also used in water_heater + ), ] diff --git a/homeassistant/components/matter/icons.json b/homeassistant/components/matter/icons.json index 0112167dbf3168..34be69aef5cb49 100644 --- a/homeassistant/components/matter/icons.json +++ b/homeassistant/components/matter/icons.json @@ -163,5 +163,10 @@ "default": "mdi:shield-lock" } } + }, + "services": { + "water_heater_boost": { + "service": "mdi:water-boiler" + } } } diff --git a/homeassistant/components/matter/services.py b/homeassistant/components/matter/services.py new file mode 100644 index 00000000000000..62a2da51a967e0 --- /dev/null +++ b/homeassistant/components/matter/services.py @@ -0,0 +1,38 @@ +"""Services for Matter devices.""" + +from __future__ import annotations + +import voluptuous as vol + +from homeassistant.components.water_heater import DOMAIN as WATER_HEATER_DOMAIN +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers import config_validation as cv, service + +from .const import DOMAIN + +ATTR_DURATION = "duration" +ATTR_EMERGENCY_BOOST = "emergency_boost" +ATTR_TEMPORARY_SETPOINT = "temporary_setpoint" + +SERVICE_WATER_HEATER_BOOST = "water_heater_boost" + + +@callback +def async_setup_services(hass: HomeAssistant) -> None: + """Register the Matter services.""" + + service.async_register_platform_entity_service( + hass, + DOMAIN, + SERVICE_WATER_HEATER_BOOST, + entity_domain=WATER_HEATER_DOMAIN, + schema={ + # duration >=1 + vol.Required(ATTR_DURATION): vol.All(vol.Coerce(int), vol.Range(min=1)), + vol.Optional(ATTR_EMERGENCY_BOOST): cv.boolean, + vol.Optional(ATTR_TEMPORARY_SETPOINT): vol.All( + vol.Coerce(int), vol.Range(min=30, max=65) + ), + }, + func="async_set_boost", + ) diff --git a/homeassistant/components/matter/services.yaml b/homeassistant/components/matter/services.yaml new file mode 100644 index 00000000000000..b860872c6ca5de --- /dev/null +++ b/homeassistant/components/matter/services.yaml @@ -0,0 +1,26 @@ +water_heater_boost: + target: + entity: + domain: water_heater + fields: + duration: + selector: + number: + min: 60 + max: 14400 + step: 60 + mode: box + default: 3600 + required: true + emergency_boost: + selector: + boolean: + default: false + temporary_setpoint: + selector: + number: + min: 30 + max: 65 + step: 1 + mode: slider + default: 65 diff --git a/homeassistant/components/matter/strings.json b/homeassistant/components/matter/strings.json index 0c0ffc84715eb3..dd501396b9042e 100644 --- a/homeassistant/components/matter/strings.json +++ b/homeassistant/components/matter/strings.json @@ -106,6 +106,9 @@ } }, "button": { + "cancel_boost": { + "name": "Cancel boost" + }, "pause": { "name": "[%key:common::action::pause%]" }, @@ -590,6 +593,24 @@ "description": "The Matter device to add to the other Matter network." } } + }, + "water_heater_boost": { + "name": "Boost water heater", + "description": "Enables water heater boost for a specific duration.", + "fields": { + "duration": { + "name": "Duration", + "description": "Boost duration" + }, + "emergency_boost": { + "name": "Emergency boost", + "description": "Whether to enable emergency boost mode." + }, + "temporary_setpoint": { + "name": "Temporary setpoint", + "description": "Temporary setpoint temperature in Celsius during the boost period." + } + } } } } diff --git a/homeassistant/components/matter/water_heater.py b/homeassistant/components/matter/water_heater.py index bd83195c2be95b..fc67d663a6e656 100644 --- a/homeassistant/components/matter/water_heater.py +++ b/homeassistant/components/matter/water_heater.py @@ -6,7 +6,9 @@ from typing import Any, cast from chip.clusters import Objects as clusters +from chip.clusters.Types import Nullable from matter_server.client.models import device_types +from matter_server.common.errors import MatterError from matter_server.common.helpers.util import create_attribute_path_from_attribute from homeassistant.components.water_heater import ( @@ -25,6 +27,7 @@ UnitOfTemperature, ) from homeassistant.core import HomeAssistant, callback +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from .entity import MatterEntity, MatterEntityDescription @@ -40,6 +43,8 @@ STATE_OFF: 0, } +DEFAULT_BOOST_DURATION = 3600 # 1 hour + async def async_setup_entry( hass: HomeAssistant, @@ -78,6 +83,30 @@ class MatterWaterHeater(MatterEntity, WaterHeaterEntity): _attr_temperature_unit = UnitOfTemperature.CELSIUS _platform_translation_key = "water_heater" + async def async_set_boost( + self, + duration: int, + emergency_boost: bool = False, + temporary_setpoint: int | None = None, + ) -> None: + """Set boost.""" + boost_info: clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct( + duration=duration, + emergencyBoost=emergency_boost, + temporarySetpoint=( + temporary_setpoint * TEMPERATURE_SCALING_FACTOR + if temporary_setpoint is not None + else Nullable + ), + ) + try: + await self.send_device_command( + clusters.WaterHeaterManagement.Commands.Boost(boostInfo=boost_info) + ) + except MatterError as err: + raise HomeAssistantError(f"Error sending Boost command: {err}") from err + self._update_from_device() + async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" target_temperature: float | None = kwargs.get(ATTR_TEMPERATURE) @@ -94,11 +123,11 @@ async def async_set_temperature(self, **kwargs: Any) -> None: async def async_set_operation_mode(self, operation_mode: str) -> None: """Set new operation mode.""" self._attr_current_operation = operation_mode - # Boost 1h (3600s) + # Use the constant for boost duration boost_info: type[ clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct ] = clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct( - duration=3600 + duration=DEFAULT_BOOST_DURATION ) system_mode_value = WATER_HEATER_SYSTEM_MODE_MAP[operation_mode] await self.write_attribute( diff --git a/homeassistant/components/nextdns/config_flow.py b/homeassistant/components/nextdns/config_flow.py index d36064d8fb04f0..637e698f112143 100644 --- a/homeassistant/components/nextdns/config_flow.py +++ b/homeassistant/components/nextdns/config_flow.py @@ -14,6 +14,7 @@ from homeassistant.config_entries import ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_API_KEY, CONF_PROFILE_NAME from homeassistant.core import HomeAssistant +from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from .const import CONF_PROFILE_ID, DOMAIN @@ -23,11 +24,40 @@ _LOGGER = logging.getLogger(__name__) -async def async_init_nextdns(hass: HomeAssistant, api_key: str) -> NextDns: - """Check if credentials are valid.""" +async def async_init_nextdns( + hass: HomeAssistant, api_key: str, profile_id: str | None = None +) -> NextDns: + """Check if credentials and profile_id are valid.""" websession = async_get_clientsession(hass) - return await NextDns.create(websession, api_key) + nextdns = await NextDns.create(websession, api_key) + + if profile_id: + if not any(profile.id == profile_id for profile in nextdns.profiles): + raise ProfileNotAvailable + + return nextdns + + +async def async_validate_new_api_key( + hass: HomeAssistant, user_input: dict[str, Any], profile_id: str +) -> dict[str, str]: + """Validate the new API key during reconfiguration or reauth.""" + errors: dict[str, str] = {} + + try: + await async_init_nextdns(hass, user_input[CONF_API_KEY], profile_id) + except InvalidApiKeyError: + errors["base"] = "invalid_api_key" + except (ApiError, ClientConnectorError, RetryError, TimeoutError): + errors["base"] = "cannot_connect" + except ProfileNotAvailable: + errors["base"] = "profile_not_available" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + + return errors class NextDnsFlowHandler(ConfigFlow, domain=DOMAIN): @@ -107,20 +137,19 @@ async def async_step_reauth_confirm( ) -> ConfigFlowResult: """Dialog that informs the user that reauth is required.""" errors: dict[str, str] = {} + entry = self._get_reauth_entry() if user_input is not None: - try: - await async_init_nextdns(self.hass, user_input[CONF_API_KEY]) - except InvalidApiKeyError: - errors["base"] = "invalid_api_key" - except (ApiError, ClientConnectorError, RetryError, TimeoutError): - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: + errors = await async_validate_new_api_key( + self.hass, user_input, entry.data[CONF_PROFILE_ID] + ) + if errors.get("base") == "profile_not_available": + return self.async_abort(reason="profile_not_available") + + if not errors: return self.async_update_reload_and_abort( - self._get_reauth_entry(), data_updates=user_input + entry, + data_updates=user_input, ) return self.async_show_form( @@ -128,3 +157,33 @@ async def async_step_reauth_confirm( data_schema=AUTH_SCHEMA, errors=errors, ) + + async def async_step_reconfigure( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle a reconfiguration flow initialized by the user.""" + errors: dict[str, str] = {} + entry = self._get_reconfigure_entry() + + if user_input is not None: + errors = await async_validate_new_api_key( + self.hass, user_input, entry.data[CONF_PROFILE_ID] + ) + if errors.get("base") == "profile_not_available": + return self.async_abort(reason="profile_not_available") + + if not errors: + return self.async_update_reload_and_abort( + entry, + data_updates=user_input, + ) + + return self.async_show_form( + step_id="reconfigure", + data_schema=AUTH_SCHEMA, + errors=errors, + ) + + +class ProfileNotAvailable(HomeAssistantError): + """Error to indicate that the profile is not available after reconfig/reauth.""" diff --git a/homeassistant/components/nextdns/manifest.json b/homeassistant/components/nextdns/manifest.json index e2cbb8612733fb..8b4acff9073345 100644 --- a/homeassistant/components/nextdns/manifest.json +++ b/homeassistant/components/nextdns/manifest.json @@ -7,6 +7,6 @@ "integration_type": "service", "iot_class": "cloud_polling", "loggers": ["nextdns"], - "quality_scale": "silver", + "quality_scale": "platinum", "requirements": ["nextdns==4.1.0"] } diff --git a/homeassistant/components/nextdns/quality_scale.yaml b/homeassistant/components/nextdns/quality_scale.yaml index 24b5b43d6d114b..c8243232b0af64 100644 --- a/homeassistant/components/nextdns/quality_scale.yaml +++ b/homeassistant/components/nextdns/quality_scale.yaml @@ -68,9 +68,7 @@ rules: entity-translations: done exception-translations: done icon-translations: done - reconfiguration-flow: - status: todo - comment: Allow API key to be changed in the re-configure flow. + reconfiguration-flow: done repair-issues: status: exempt comment: This integration doesn't have any cases where raising an issue is needed. diff --git a/homeassistant/components/nextdns/strings.json b/homeassistant/components/nextdns/strings.json index 2d12fb066d9a72..700e7a889f4679 100644 --- a/homeassistant/components/nextdns/strings.json +++ b/homeassistant/components/nextdns/strings.json @@ -24,6 +24,14 @@ "data_description": { "api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]" } + }, + "reconfigure": { + "data": { + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "data_description": { + "api_key": "[%key:component::nextdns::config::step::user::data_description::api_key%]" + } } }, "error": { @@ -33,7 +41,9 @@ }, "abort": { "already_configured": "This NextDNS profile is already configured.", - "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + "profile_not_available": "The configured NextDNS profile is no longer available in your account. Remove the configuration and configure the integration again.", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]", + "reconfigure_successful": "[%key:common::config_flow::abort::reconfigure_successful%]" } }, "system_health": { diff --git a/homeassistant/components/portainer/manifest.json b/homeassistant/components/portainer/manifest.json index 8e6c204ee5310b..d2856b50c8b684 100644 --- a/homeassistant/components/portainer/manifest.json +++ b/homeassistant/components/portainer/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/portainer", "iot_class": "local_polling", "quality_scale": "bronze", - "requirements": ["pyportainer==1.0.7"] + "requirements": ["pyportainer==1.0.8"] } diff --git a/homeassistant/components/shelly/switch.py b/homeassistant/components/shelly/switch.py index 681d80ec49eb27..c3c1619f24197f 100644 --- a/homeassistant/components/shelly/switch.py +++ b/homeassistant/components/shelly/switch.py @@ -165,6 +165,7 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): "boolean_zone0": RpcSwitchDescription( key="boolean", sub_key="value", + entity_registry_enabled_default=False, is_on=lambda status: bool(status["value"]), method_on="boolean_set", method_off="boolean_set", @@ -175,6 +176,7 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): "boolean_zone1": RpcSwitchDescription( key="boolean", sub_key="value", + entity_registry_enabled_default=False, is_on=lambda status: bool(status["value"]), method_on="boolean_set", method_off="boolean_set", @@ -185,6 +187,7 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): "boolean_zone2": RpcSwitchDescription( key="boolean", sub_key="value", + entity_registry_enabled_default=False, is_on=lambda status: bool(status["value"]), method_on="boolean_set", method_off="boolean_set", @@ -195,6 +198,7 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): "boolean_zone3": RpcSwitchDescription( key="boolean", sub_key="value", + entity_registry_enabled_default=False, is_on=lambda status: bool(status["value"]), method_on="boolean_set", method_off="boolean_set", @@ -205,6 +209,7 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): "boolean_zone4": RpcSwitchDescription( key="boolean", sub_key="value", + entity_registry_enabled_default=False, is_on=lambda status: bool(status["value"]), method_on="boolean_set", method_off="boolean_set", @@ -215,6 +220,7 @@ class RpcSwitchDescription(RpcEntityDescription, SwitchEntityDescription): "boolean_zone5": RpcSwitchDescription( key="boolean", sub_key="value", + entity_registry_enabled_default=False, is_on=lambda status: bool(status["value"]), method_on="boolean_set", method_off="boolean_set", diff --git a/homeassistant/components/shelly/utils.py b/homeassistant/components/shelly/utils.py index 36478a3dc6205c..5c1dac75d284a3 100644 --- a/homeassistant/components/shelly/utils.py +++ b/homeassistant/components/shelly/utils.py @@ -417,6 +417,11 @@ def get_rpc_sub_device_name( """Get name based on device and channel name.""" if key in device.config and key != "em:0": # workaround for Pro 3EM, we don't want to get name for em:0 + if (zone_id := get_irrigation_zone_id(device.config, key)) is not None: + # workaround for Irrigation controller, name stored in "service:0" + if zone_name := device.config["service:0"]["zones"][zone_id]["name"]: + return cast(str, zone_name) + if entity_name := device.config[key].get("name"): return cast(str, entity_name) @@ -787,6 +792,13 @@ async def get_rpc_scripts_event_types( return script_events +def get_irrigation_zone_id(config: dict[str, Any], key: str) -> int | None: + """Return the zone id if the component is an irrigation zone.""" + if key in config and (zone := get_rpc_role_by_key(config, key)).startswith("zone"): + return int(zone[4:]) + return None + + def get_rpc_device_info( device: RpcDevice, mac: str, @@ -823,7 +835,10 @@ def get_rpc_device_info( ) if ( - component not in (*All_LIGHT_TYPES, "cover", "em1", "switch") + ( + component not in (*All_LIGHT_TYPES, "cover", "em1", "switch") + and get_irrigation_zone_id(device.config, key) is None + ) or idx is None or len(get_rpc_key_instances(device.status, component, all_lights=True)) < 2 ): diff --git a/homeassistant/components/shelly/valve.py b/homeassistant/components/shelly/valve.py index ada262e7bbf3ca..572cd5e08d4d12 100644 --- a/homeassistant/components/shelly/valve.py +++ b/homeassistant/components/shelly/valve.py @@ -17,7 +17,11 @@ from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback -from .const import MODEL_FRANKEVER_WATER_VALVE, MODEL_NEO_WATER_VALVE +from .const import ( + MODEL_FRANKEVER_IRRIGATION_CONTROLLER, + MODEL_FRANKEVER_WATER_VALVE, + MODEL_NEO_WATER_VALVE, +) from .coordinator import ShellyBlockCoordinator, ShellyConfigEntry, ShellyRpcCoordinator from .entity import ( BlockEntityDescription, @@ -92,8 +96,8 @@ async def async_set_valve_position(self, position: int) -> None: await self.coordinator.device.number_set(self._id, position) -class RpcShellyNeoWaterValve(RpcShellyBaseWaterValve): - """Entity that controls a valve on RPC Shelly NEO Water Valve.""" +class RpcShellySimpleWaterValve(RpcShellyBaseWaterValve): + """Entity that controls a valve on RPC Shelly Open/Close Water Valve.""" _attr_supported_features = ValveEntityFeature.OPEN | ValveEntityFeature.CLOSE _attr_reports_position = False @@ -124,9 +128,51 @@ async def async_close_valve(self, **kwargs: Any) -> None: key="boolean", sub_key="value", role="state", - entity_class=RpcShellyNeoWaterValve, + entity_class=RpcShellySimpleWaterValve, models={MODEL_NEO_WATER_VALVE}, ), + "boolean_zone0": RpcValveDescription( + key="boolean", + sub_key="value", + role="zone0", + entity_class=RpcShellySimpleWaterValve, + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), + "boolean_zone1": RpcValveDescription( + key="boolean", + sub_key="value", + role="zone1", + entity_class=RpcShellySimpleWaterValve, + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), + "boolean_zone2": RpcValveDescription( + key="boolean", + sub_key="value", + role="zone2", + entity_class=RpcShellySimpleWaterValve, + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), + "boolean_zone3": RpcValveDescription( + key="boolean", + sub_key="value", + role="zone3", + entity_class=RpcShellySimpleWaterValve, + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), + "boolean_zone4": RpcValveDescription( + key="boolean", + sub_key="value", + role="zone4", + entity_class=RpcShellySimpleWaterValve, + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), + "boolean_zone5": RpcValveDescription( + key="boolean", + sub_key="value", + role="zone5", + entity_class=RpcShellySimpleWaterValve, + models={MODEL_FRANKEVER_IRRIGATION_CONTROLLER}, + ), } diff --git a/homeassistant/components/sunricher_dali_center/__init__.py b/homeassistant/components/sunricher_dali_center/__init__.py new file mode 100644 index 00000000000000..75b08a16d21dad --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/__init__.py @@ -0,0 +1,88 @@ +"""The DALI Center integration.""" + +from __future__ import annotations + +import logging + +from PySrDaliGateway import DaliGateway +from PySrDaliGateway.exceptions import DaliGatewayError + +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + Platform, +) +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryNotReady +from homeassistant.helpers import device_registry as dr +from homeassistant.helpers.dispatcher import async_dispatcher_send + +from .const import CONF_SERIAL_NUMBER, DOMAIN, MANUFACTURER +from .types import DaliCenterConfigEntry, DaliCenterData + +_PLATFORMS: list[Platform] = [Platform.LIGHT] +_LOGGER = logging.getLogger(__name__) + + +async def async_setup_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool: + """Set up DALI Center from a config entry.""" + + gateway = DaliGateway( + entry.data[CONF_SERIAL_NUMBER], + entry.data[CONF_HOST], + entry.data[CONF_PORT], + entry.data[CONF_USERNAME], + entry.data[CONF_PASSWORD], + name=entry.data[CONF_NAME], + ) + gw_sn = gateway.gw_sn + + try: + await gateway.connect() + except DaliGatewayError as exc: + raise ConfigEntryNotReady( + "You can try to delete the gateway and add it again" + ) from exc + + def on_online_status(dev_id: str, available: bool) -> None: + signal = f"{DOMAIN}_update_available_{dev_id}" + hass.add_job(async_dispatcher_send, hass, signal, available) + + gateway.on_online_status = on_online_status + + try: + devices = await gateway.discover_devices() + except DaliGatewayError as exc: + raise ConfigEntryNotReady( + "Unable to discover devices from the gateway" + ) from exc + + _LOGGER.debug("Discovered %d devices on gateway %s", len(devices), gw_sn) + + dev_reg = dr.async_get(hass) + dev_reg.async_get_or_create( + config_entry_id=entry.entry_id, + identifiers={(DOMAIN, gw_sn)}, + manufacturer=MANUFACTURER, + name=gateway.name, + model="SR-GW-EDA", + serial_number=gw_sn, + ) + + entry.runtime_data = DaliCenterData( + gateway=gateway, + devices=devices, + ) + await hass.config_entries.async_forward_entry_setups(entry, _PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: DaliCenterConfigEntry) -> bool: + """Unload a config entry.""" + if unload_ok := await hass.config_entries.async_unload_platforms(entry, _PLATFORMS): + await entry.runtime_data.gateway.disconnect() + return unload_ok diff --git a/homeassistant/components/sunricher_dali_center/config_flow.py b/homeassistant/components/sunricher_dali_center/config_flow.py new file mode 100644 index 00000000000000..84b1c692920495 --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/config_flow.py @@ -0,0 +1,134 @@ +"""Config flow for the DALI Center integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from PySrDaliGateway import DaliGateway +from PySrDaliGateway.discovery import DaliGatewayDiscovery +from PySrDaliGateway.exceptions import DaliGatewayError +import voluptuous as vol + +from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.helpers.selector import ( + SelectOptionDict, + SelectSelector, + SelectSelectorConfig, +) + +from .const import CONF_SERIAL_NUMBER, DOMAIN + +_LOGGER = logging.getLogger(__name__) + + +class DaliCenterConfigFlow(ConfigFlow, domain=DOMAIN): + """Handle a config flow for DALI Center.""" + + VERSION = 1 + + def __init__(self) -> None: + """Initialize the config flow.""" + self._discovered_gateways: dict[str, DaliGateway] = {} + + async def async_step_user( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + if user_input is not None: + return await self.async_step_select_gateway() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema({}), + ) + + async def async_step_select_gateway( + self, discovery_info: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle gateway discovery.""" + errors: dict[str, str] = {} + + if discovery_info and "selected_gateway" in discovery_info: + selected_sn = discovery_info["selected_gateway"] + selected_gateway = self._discovered_gateways[selected_sn] + + await self.async_set_unique_id(selected_gateway.gw_sn) + self._abort_if_unique_id_configured() + + try: + await selected_gateway.connect() + except DaliGatewayError as err: + _LOGGER.debug( + "Failed to connect to gateway %s during config flow", + selected_gateway.gw_sn, + exc_info=err, + ) + errors["base"] = "cannot_connect" + else: + await selected_gateway.disconnect() + return self.async_create_entry( + title=selected_gateway.name, + data={ + CONF_SERIAL_NUMBER: selected_gateway.gw_sn, + CONF_HOST: selected_gateway.gw_ip, + CONF_PORT: selected_gateway.port, + CONF_NAME: selected_gateway.name, + CONF_USERNAME: selected_gateway.username, + CONF_PASSWORD: selected_gateway.passwd, + }, + ) + + if not self._discovered_gateways: + _LOGGER.debug("Starting gateway discovery") + discovery = DaliGatewayDiscovery() + try: + discovered = await discovery.discover_gateways() + except DaliGatewayError as err: + _LOGGER.debug("Gateway discovery failed", exc_info=err) + errors["base"] = "discovery_failed" + else: + configured_gateways = { + entry.data[CONF_SERIAL_NUMBER] + for entry in self.hass.config_entries.async_entries(DOMAIN) + } + + self._discovered_gateways = { + gw.gw_sn: gw + for gw in discovered + if gw.gw_sn not in configured_gateways + } + + if not self._discovered_gateways: + return self.async_show_form( + step_id="select_gateway", + errors=errors if errors else {"base": "no_devices_found"}, + data_schema=vol.Schema({}), + ) + + gateway_options = [ + SelectOptionDict( + value=sn, + label=f"{gateway.name} [SN {sn}, IP {gateway.gw_ip}]", + ) + for sn, gateway in self._discovered_gateways.items() + ] + + return self.async_show_form( + step_id="select_gateway", + data_schema=vol.Schema( + { + vol.Optional("selected_gateway"): SelectSelector( + SelectSelectorConfig(options=gateway_options, sort=True) + ), + } + ), + errors=errors, + ) diff --git a/homeassistant/components/sunricher_dali_center/const.py b/homeassistant/components/sunricher_dali_center/const.py new file mode 100644 index 00000000000000..7e78b441d52863 --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/const.py @@ -0,0 +1,5 @@ +"""Constants for the DALI Center integration.""" + +DOMAIN = "sunricher_dali_center" +MANUFACTURER = "Sunricher" +CONF_SERIAL_NUMBER = "serial_number" diff --git a/homeassistant/components/sunricher_dali_center/light.py b/homeassistant/components/sunricher_dali_center/light.py new file mode 100644 index 00000000000000..a73422ca33492b --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/light.py @@ -0,0 +1,190 @@ +"""Platform for light integration.""" + +from __future__ import annotations + +import logging +from typing import Any + +from PySrDaliGateway import Device +from PySrDaliGateway.helper import is_light_device +from PySrDaliGateway.types import LightStatus + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + ATTR_COLOR_TEMP_KELVIN, + ATTR_HS_COLOR, + ATTR_RGBW_COLOR, + ColorMode, + LightEntity, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.dispatcher import ( + async_dispatcher_connect, + async_dispatcher_send, +) +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from .const import DOMAIN, MANUFACTURER +from .types import DaliCenterConfigEntry + +_LOGGER = logging.getLogger(__name__) + +PARALLEL_UPDATES = 0 + + +async def async_setup_entry( + hass: HomeAssistant, + entry: DaliCenterConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up DALI Center light entities from config entry.""" + runtime_data = entry.runtime_data + gateway = runtime_data.gateway + devices = runtime_data.devices + + def _on_light_status(dev_id: str, status: LightStatus) -> None: + signal = f"{DOMAIN}_update_{dev_id}" + hass.add_job(async_dispatcher_send, hass, signal, status) + + gateway.on_light_status = _on_light_status + + async_add_entities( + DaliCenterLight(device) + for device in devices + if is_light_device(device.dev_type) + ) + + +class DaliCenterLight(LightEntity): + """Representation of a DALI Center Light.""" + + _attr_has_entity_name = True + _attr_name = None + _attr_is_on: bool | None = None + _attr_brightness: int | None = None + _white_level: int | None = None + _attr_color_mode: ColorMode | str | None = None + _attr_color_temp_kelvin: int | None = None + _attr_hs_color: tuple[float, float] | None = None + _attr_rgbw_color: tuple[int, int, int, int] | None = None + + def __init__(self, light: Device) -> None: + """Initialize the light entity.""" + + self._light = light + self._unavailable_logged = False + self._attr_unique_id = light.unique_id + self._attr_available = light.status == "online" + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, light.dev_id)}, + name=light.name, + manufacturer=MANUFACTURER, + model=light.model, + via_device=(DOMAIN, light.gw_sn), + ) + self._attr_min_color_temp_kelvin = 1000 + self._attr_max_color_temp_kelvin = 8000 + + self._determine_features() + + def _determine_features(self) -> None: + supported_modes: set[ColorMode] = set() + color_mode = self._light.color_mode + color_mode_map: dict[str, ColorMode] = { + "color_temp": ColorMode.COLOR_TEMP, + "hs": ColorMode.HS, + "rgbw": ColorMode.RGBW, + } + self._attr_color_mode = color_mode_map.get(color_mode, ColorMode.BRIGHTNESS) + supported_modes.add(self._attr_color_mode) + self._attr_supported_color_modes = supported_modes + + async def async_turn_on(self, **kwargs: Any) -> None: + """Turn on the light.""" + _LOGGER.debug( + "Turning on light %s with kwargs: %s", self._attr_unique_id, kwargs + ) + brightness = kwargs.get(ATTR_BRIGHTNESS) + color_temp_kelvin = kwargs.get(ATTR_COLOR_TEMP_KELVIN) + hs_color = kwargs.get(ATTR_HS_COLOR) + rgbw_color = kwargs.get(ATTR_RGBW_COLOR) + self._light.turn_on( + brightness=brightness, + color_temp_kelvin=color_temp_kelvin, + hs_color=hs_color, + rgbw_color=rgbw_color, + ) + + async def async_turn_off(self, **kwargs: Any) -> None: + """Turn off the light.""" + self._light.turn_off() + + async def async_added_to_hass(self) -> None: + """Handle entity addition to Home Assistant.""" + + signal = f"{DOMAIN}_update_{self._attr_unique_id}" + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, self._handle_device_update) + ) + + signal = f"{DOMAIN}_update_available_{self._attr_unique_id}" + self.async_on_remove( + async_dispatcher_connect(self.hass, signal, self._handle_availability) + ) + + # read_status() only queues a request on the gateway and relies on the + # current event loop via call_later, so it must run in the loop thread. + self._light.read_status() + + @callback + def _handle_availability(self, available: bool) -> None: + self._attr_available = available + if not available and not self._unavailable_logged: + _LOGGER.info("Light %s became unavailable", self._attr_unique_id) + self._unavailable_logged = True + elif available and self._unavailable_logged: + _LOGGER.info("Light %s is back online", self._attr_unique_id) + self._unavailable_logged = False + self.schedule_update_ha_state() + + @callback + def _handle_device_update(self, status: LightStatus) -> None: + if status.get("is_on") is not None: + self._attr_is_on = status["is_on"] + + if status.get("brightness") is not None: + self._attr_brightness = status["brightness"] + + if status.get("white_level") is not None: + self._white_level = status["white_level"] + if self._attr_rgbw_color is not None and self._white_level is not None: + self._attr_rgbw_color = ( + self._attr_rgbw_color[0], + self._attr_rgbw_color[1], + self._attr_rgbw_color[2], + self._white_level, + ) + + if ( + status.get("color_temp_kelvin") is not None + and self._attr_supported_color_modes + and ColorMode.COLOR_TEMP in self._attr_supported_color_modes + ): + self._attr_color_temp_kelvin = status["color_temp_kelvin"] + + if ( + status.get("hs_color") is not None + and self._attr_supported_color_modes + and ColorMode.HS in self._attr_supported_color_modes + ): + self._attr_hs_color = status["hs_color"] + + if ( + status.get("rgbw_color") is not None + and self._attr_supported_color_modes + and ColorMode.RGBW in self._attr_supported_color_modes + ): + self._attr_rgbw_color = status["rgbw_color"] + + self.async_write_ha_state() diff --git a/homeassistant/components/sunricher_dali_center/manifest.json b/homeassistant/components/sunricher_dali_center/manifest.json new file mode 100644 index 00000000000000..1f68d484f7811e --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/manifest.json @@ -0,0 +1,10 @@ +{ + "domain": "sunricher_dali_center", + "name": "DALI Center", + "codeowners": ["@niracler"], + "config_flow": true, + "documentation": "https://www.home-assistant.io/integrations/sunricher_dali_center", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["PySrDaliGateway==0.13.1"] +} diff --git a/homeassistant/components/sunricher_dali_center/quality_scale.yaml b/homeassistant/components/sunricher_dali_center/quality_scale.yaml new file mode 100644 index 00000000000000..dd4e2fff823207 --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/quality_scale.yaml @@ -0,0 +1,70 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: Integration does not register custom actions. + appropriate-polling: done + brands: done + common-modules: done + config-flow-test-coverage: done + config-flow: done + dependency-transparency: done + docs-actions: + status: exempt + comment: Integration does not register custom actions. + 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: Integration does not register custom actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: done + integration-owner: todo + log-when-unavailable: done + parallel-updates: done + reauthentication-flow: todo + test-coverage: todo + + # Gold + devices: done + diagnostics: todo + discovery-update-info: todo + discovery: todo + 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: + status: exempt + comment: Integration exposes only primary light entities. + entity-device-class: + status: exempt + comment: Light entities do not support device classes. + 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: todo + strict-typing: todo diff --git a/homeassistant/components/sunricher_dali_center/strings.json b/homeassistant/components/sunricher_dali_center/strings.json new file mode 100644 index 00000000000000..c9fe7aaeeaf8f4 --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/strings.json @@ -0,0 +1,29 @@ +{ + "config": { + "step": { + "user": { + "title": "Set up DALI Center gateway", + "description": "**Three-step process:**\n\n1. Ensure the gateway is powered and on the same network.\n2. Select **Submit** to start discovery (searches for up to 3 minutes)\n3. While discovery is in progress, press the **Reset** button on your DALI gateway device **once**.\n\nThe gateway will respond immediately after the button press." + }, + "select_gateway": { + "title": "Select DALI gateway", + "description": "Select the gateway to configure.", + "data": { + "selected_gateway": "Gateway" + }, + "data_description": { + "selected_gateway": "Each option shows the gateway name, serial number, and IP address." + } + } + }, + "error": { + "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", + "discovery_failed": "Failed to discover DALI gateways on the network", + "no_devices_found": "No DALI gateways found on the network", + "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_device%]" + } + } +} diff --git a/homeassistant/components/sunricher_dali_center/types.py b/homeassistant/components/sunricher_dali_center/types.py new file mode 100644 index 00000000000000..8307360f62cae0 --- /dev/null +++ b/homeassistant/components/sunricher_dali_center/types.py @@ -0,0 +1,18 @@ +"""Type definitions for the DALI Center integration.""" + +from dataclasses import dataclass + +from PySrDaliGateway import DaliGateway, Device + +from homeassistant.config_entries import ConfigEntry + + +@dataclass +class DaliCenterData: + """Runtime data for the DALI Center integration.""" + + gateway: DaliGateway + devices: list[Device] + + +type DaliCenterConfigEntry = ConfigEntry[DaliCenterData] diff --git a/homeassistant/components/switchbot/__init__.py b/homeassistant/components/switchbot/__init__.py index 415ba4d48dafab..cd8a8b08f1d336 100644 --- a/homeassistant/components/switchbot/__init__.py +++ b/homeassistant/components/switchbot/__init__.py @@ -101,6 +101,7 @@ SupportedModels.PLUG_MINI_EU.value: [Platform.SWITCH, Platform.SENSOR], SupportedModels.RELAY_SWITCH_2PM.value: [Platform.SWITCH, Platform.SENSOR], SupportedModels.GARAGE_DOOR_OPENER.value: [Platform.COVER, Platform.SENSOR], + SupportedModels.CLIMATE_PANEL.value: [Platform.SENSOR, Platform.BINARY_SENSOR], } CLASS_BY_DEVICE = { SupportedModels.CEILING_LIGHT.value: switchbot.SwitchbotCeilingLight, diff --git a/homeassistant/components/switchbot/binary_sensor.py b/homeassistant/components/switchbot/binary_sensor.py index 6d1490c895b3ad..fc87ad2e497528 100644 --- a/homeassistant/components/switchbot/binary_sensor.py +++ b/homeassistant/components/switchbot/binary_sensor.py @@ -24,7 +24,6 @@ ), "motion_detected": BinarySensorEntityDescription( key="pir_state", - name=None, device_class=BinarySensorDeviceClass.MOTION, ), "contact_open": BinarySensorEntityDescription( diff --git a/homeassistant/components/switchbot/const.py b/homeassistant/components/switchbot/const.py index 80f7978f4dc315..4c29ed7d67298f 100644 --- a/homeassistant/components/switchbot/const.py +++ b/homeassistant/components/switchbot/const.py @@ -57,6 +57,7 @@ class SupportedModels(StrEnum): RELAY_SWITCH_2PM = "relay_switch_2pm" K11_PLUS_VACUUM = "k11+_vacuum" GARAGE_DOOR_OPENER = "garage_door_opener" + CLIMATE_PANEL = "climate_panel" CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -93,6 +94,7 @@ class SupportedModels(StrEnum): SwitchbotModel.RELAY_SWITCH_2PM: SupportedModels.RELAY_SWITCH_2PM, SwitchbotModel.K11_VACUUM: SupportedModels.K11_PLUS_VACUUM, SwitchbotModel.GARAGE_DOOR_OPENER: SupportedModels.GARAGE_DOOR_OPENER, + SwitchbotModel.CLIMATE_PANEL: SupportedModels.CLIMATE_PANEL, } NON_CONNECTABLE_SUPPORTED_MODEL_TYPES = { @@ -106,6 +108,7 @@ class SupportedModels(StrEnum): SwitchbotModel.REMOTE: SupportedModels.REMOTE, SwitchbotModel.HUBMINI_MATTER: SupportedModels.HUBMINI_MATTER, SwitchbotModel.HUB3: SupportedModels.HUB3, + SwitchbotModel.CLIMATE_PANEL: SupportedModels.CLIMATE_PANEL, } SUPPORTED_MODEL_TYPES = ( diff --git a/homeassistant/components/telegram_bot/manifest.json b/homeassistant/components/telegram_bot/manifest.json index 7a01f43c5286b7..d73e55cafe4f99 100644 --- a/homeassistant/components/telegram_bot/manifest.json +++ b/homeassistant/components/telegram_bot/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_push", "loggers": ["telegram"], "quality_scale": "bronze", - "requirements": ["python-telegram-bot[socks]==21.5"] + "requirements": ["python-telegram-bot[socks]==22.1"] } diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index f3d23183f55737..9a924853c4e39c 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -641,6 +641,7 @@ "subaru", "suez_water", "sun", + "sunricher_dali_center", "sunweg", "surepetcare", "swiss_public_transport", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 7b933a735bf7f7..730a77bccd948e 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6489,6 +6489,12 @@ "iot_class": "calculated", "single_config_entry": true }, + "sunricher_dali_center": { + "name": "DALI Center", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "sunweg": { "name": "Sun WEG", "integration_type": "hub", diff --git a/requirements_all.txt b/requirements_all.txt index cb4fbf292cccc9..a365a8727dd87d 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -83,6 +83,9 @@ PyQRCode==1.2.1 # homeassistant.components.rmvtransport PyRMVtransport==0.3.3 +# homeassistant.components.sunricher_dali_center +PySrDaliGateway==0.13.1 + # homeassistant.components.switchbot PySwitchbot==0.72.0 @@ -1204,7 +1207,7 @@ home-assistant-frontend==20251001.4 home-assistant-intents==2025.10.1 # homeassistant.components.homematicip_cloud -homematicip==2.3.0 +homematicip==2.3.1 # homeassistant.components.horizon horimote==0.4.1 @@ -2305,7 +2308,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.7 +pyportainer==1.0.8 # homeassistant.components.probe_plus pyprobeplus==1.1.2 @@ -2580,7 +2583,7 @@ python-tado==0.18.15 python-technove==2.0.0 # homeassistant.components.telegram_bot -python-telegram-bot[socks]==21.5 +python-telegram-bot[socks]==22.1 # homeassistant.components.vlc python-vlc==3.0.18122 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 31a5ea641388d7..9c87033b2883b4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -80,6 +80,9 @@ PyQRCode==1.2.1 # homeassistant.components.rmvtransport PyRMVtransport==0.3.3 +# homeassistant.components.sunricher_dali_center +PySrDaliGateway==0.13.1 + # homeassistant.components.switchbot PySwitchbot==0.72.0 @@ -1053,7 +1056,7 @@ home-assistant-frontend==20251001.4 home-assistant-intents==2025.10.1 # homeassistant.components.homematicip_cloud -homematicip==2.3.0 +homematicip==2.3.1 # homeassistant.components.remember_the_milk httplib2==0.20.4 @@ -1932,7 +1935,7 @@ pyplaato==0.0.19 pypoint==3.0.0 # homeassistant.components.portainer -pyportainer==1.0.7 +pyportainer==1.0.8 # homeassistant.components.probe_plus pyprobeplus==1.1.2 @@ -2150,7 +2153,7 @@ python-tado==0.18.15 python-technove==2.0.0 # homeassistant.components.telegram_bot -python-telegram-bot[socks]==21.5 +python-telegram-bot[socks]==22.1 # homeassistant.components.uptime_kuma pythonkuma==0.3.1 diff --git a/tests/components/brother/conftest.py b/tests/components/brother/conftest.py index 82a8d52a76ef19..09860f5a0db12f 100644 --- a/tests/components/brother/conftest.py +++ b/tests/components/brother/conftest.py @@ -2,7 +2,7 @@ from collections.abc import Generator from datetime import UTC, datetime -from unittest.mock import AsyncMock, MagicMock, patch +from unittest.mock import AsyncMock, patch from brother import BrotherSensors import pytest @@ -100,23 +100,26 @@ def mock_unload_entry() -> Generator[AsyncMock]: @pytest.fixture -def mock_brother_client() -> Generator[MagicMock]: - """Mock Brother client.""" +def mock_brother() -> Generator[AsyncMock]: + """Mock the Brother class.""" with ( - patch("homeassistant.components.brother.Brother", autospec=True) as mock_client, - patch( - "homeassistant.components.brother.config_flow.Brother", - new=mock_client, - ), + patch("homeassistant.components.brother.Brother", autospec=True) as mock_class, + patch("homeassistant.components.brother.config_flow.Brother", new=mock_class), ): - client = mock_client.create.return_value - client.async_update.return_value = BROTHER_DATA - client.serial = "0123456789" - client.mac = "AA:BB:CC:DD:EE:FF" - client.model = "HL-L2340DW" - client.firmware = "1.2.3" - - yield client + yield mock_class + + +@pytest.fixture +def mock_brother_client(mock_brother: AsyncMock) -> AsyncMock: + """Mock Brother client.""" + client = mock_brother.create.return_value + client.async_update.return_value = BROTHER_DATA + client.serial = "0123456789" + client.mac = "AA:BB:CC:DD:EE:FF" + client.model = "HL-L2340DW" + client.firmware = "1.2.3" + + return client @pytest.fixture diff --git a/tests/components/brother/test_config_flow.py b/tests/components/brother/test_config_flow.py index dfec3077832b20..e2a3645f2c4b40 100644 --- a/tests/components/brother/test_config_flow.py +++ b/tests/components/brother/test_config_flow.py @@ -98,15 +98,14 @@ async def test_errors( assert result["errors"] == {"base": base_error} -async def test_unsupported_model_error(hass: HomeAssistant) -> None: +async def test_unsupported_model_error( + hass: HomeAssistant, mock_brother: AsyncMock, mock_brother_client: AsyncMock +) -> None: """Test unsupported printer model error.""" - with patch( - "homeassistant.components.brother.Brother.create", - new=AsyncMock(side_effect=UnsupportedModelError("error")), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, context={"source": SOURCE_USER}, data=CONFIG - ) + mock_brother.create.side_effect = UnsupportedModelError("error") + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER}, data=CONFIG + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unsupported_model" @@ -153,25 +152,24 @@ async def test_zeroconf_exception( assert result["reason"] == "cannot_connect" -async def test_zeroconf_unsupported_model(hass: HomeAssistant) -> None: +async def test_zeroconf_unsupported_model( + hass: HomeAssistant, mock_brother: AsyncMock, mock_brother_client: AsyncMock +) -> None: """Test unsupported printer model error.""" - with patch( - "homeassistant.components.brother.Brother.create", - new=AsyncMock(side_effect=UnsupportedModelError("error")), - ): - result = await hass.config_entries.flow.async_init( - DOMAIN, - context={"source": SOURCE_ZEROCONF}, - data=ZeroconfServiceInfo( - ip_address=ip_address("127.0.0.1"), - ip_addresses=[ip_address("127.0.0.1")], - hostname="example.local.", - name="Brother Printer", - port=None, - properties={"product": "MFC-8660DN"}, - type="mock_type", - ), - ) + mock_brother.create.side_effect = UnsupportedModelError("error") + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_ZEROCONF}, + data=ZeroconfServiceInfo( + ip_address=ip_address("127.0.0.1"), + ip_addresses=[ip_address("127.0.0.1")], + hostname="example.local.", + name="Brother Printer", + port=None, + properties={"product": "MFC-8660DN"}, + type="mock_type", + ), + ) assert result["type"] is FlowResultType.ABORT assert result["reason"] == "unsupported_model" diff --git a/tests/components/brother/test_init.py b/tests/components/brother/test_init.py index 45702d91f2063a..d76afa3e6f64c1 100644 --- a/tests/components/brother/test_init.py +++ b/tests/components/brother/test_init.py @@ -1,6 +1,6 @@ """Test init of Brother integration.""" -from unittest.mock import AsyncMock, patch +from unittest.mock import AsyncMock from brother import SnmpError import pytest @@ -45,14 +45,16 @@ async def test_config_not_ready( @pytest.mark.parametrize("exc", [(SnmpError("SNMP Error")), (ConnectionError)]) async def test_error_on_init( - hass: HomeAssistant, exc: Exception, mock_config_entry: MockConfigEntry + hass: HomeAssistant, + exc: Exception, + mock_config_entry: MockConfigEntry, + mock_brother: AsyncMock, + mock_brother_client: AsyncMock, ) -> None: """Test for error on init.""" - with patch( - "homeassistant.components.brother.Brother.create", - new=AsyncMock(side_effect=exc), - ): - await init_integration(hass, mock_config_entry) + mock_brother.create.side_effect = exc + + await init_integration(hass, mock_config_entry) assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY diff --git a/tests/components/cert_expiry/test_config_flow.py b/tests/components/cert_expiry/test_config_flow.py index 907071d8b1f7a9..58ed8399b762c4 100644 --- a/tests/components/cert_expiry/test_config_flow.py +++ b/tests/components/cert_expiry/test_config_flow.py @@ -115,3 +115,13 @@ async def test_abort_on_socket_failed(hass: HomeAssistant) -> None: ) assert result["type"] is FlowResultType.FORM assert result["errors"] == {CONF_HOST: "connection_refused"} + + with patch( + "homeassistant.components.cert_expiry.helper.async_get_cert", + side_effect=ConnectionResetError, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={CONF_HOST: HOST} + ) + assert result["type"] is FlowResultType.FORM + assert result["errors"] == {CONF_HOST: "connection_reset"} diff --git a/tests/components/deconz/test_config_flow.py b/tests/components/deconz/test_config_flow.py index 50a6066d952e9b..b7e62729d3b297 100644 --- a/tests/components/deconz/test_config_flow.py +++ b/tests/components/deconz/test_config_flow.py @@ -483,7 +483,7 @@ async def test_ssdp_discovery_update_configuration( with patch( "homeassistant.components.deconz.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ): result = await hass.config_entries.flow.async_init( DOMAIN, data=SsdpServiceInfo( @@ -502,7 +502,6 @@ async def test_ssdp_discovery_update_configuration( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "already_configured" assert config_entry_setup.data[CONF_HOST] == "2.3.4.5" - assert len(mock_setup_entry.mock_calls) == 1 async def test_ssdp_discovery_dont_update_configuration( @@ -607,7 +606,7 @@ async def test_hassio_discovery_update_configuration( with patch( "homeassistant.components.deconz.async_setup_entry", return_value=True, - ) as mock_setup_entry: + ): result = await hass.config_entries.flow.async_init( DOMAIN, data=HassioServiceInfo( @@ -630,7 +629,6 @@ async def test_hassio_discovery_update_configuration( assert config_entry_setup.data[CONF_HOST] == "2.3.4.5" assert config_entry_setup.data[CONF_PORT] == 8080 assert config_entry_setup.data[CONF_API_KEY] == "updated" - assert len(mock_setup_entry.mock_calls) == 1 @pytest.mark.usefixtures("config_entry_setup") diff --git a/tests/components/deconz/test_hub.py b/tests/components/deconz/test_hub.py index cf5edc85a2db2e..3f5284514866c6 100644 --- a/tests/components/deconz/test_hub.py +++ b/tests/components/deconz/test_hub.py @@ -76,7 +76,7 @@ async def test_update_address( patch( "homeassistant.components.deconz.async_setup_entry", return_value=True, - ) as mock_setup_entry, + ), patch("pydeconz.gateway.WSClient") as ws_mock, ): await hass.config_entries.flow.async_init( @@ -97,4 +97,3 @@ async def test_update_address( assert ws_mock.call_args[0][1] == "2.3.4.5" assert config_entry_setup.data["host"] == "2.3.4.5" - assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/homewizard/test_init.py b/tests/components/homewizard/test_init.py index b0562afbb3dd50..9b8d6e1d2df05f 100644 --- a/tests/components/homewizard/test_init.py +++ b/tests/components/homewizard/test_init.py @@ -1,16 +1,18 @@ """Tests for the homewizard component.""" from datetime import timedelta -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch import weakref from freezegun.api import FrozenDateTimeFactory from homewizard_energy.errors import DisabledError, UnauthorizedError import pytest +from homeassistant.components.homewizard import get_main_device from homeassistant.components.homewizard.const import DOMAIN from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, issue_registry as ir from tests.common import MockConfigEntry, async_fire_time_changed @@ -122,6 +124,78 @@ async def test_load_detect_invalid_token( assert flow["context"].get("entry_id") == mock_config_entry_v2.entry_id +@pytest.mark.usefixtures("mock_homewizardenergy") +async def test_load_creates_repair_issue( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, +) -> None: + """Test setup creates repair issue for v2 API upgrade.""" + mock_config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + with patch("homeassistant.components.homewizard.has_v2_api", return_value=True): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + + issue = issue_registry.async_get_issue( + domain=DOMAIN, issue_id=f"migrate_to_v2_api_{mock_config_entry.entry_id}" + ) + assert issue is not None + + # Make sure title placeholder is set correctly + assert issue.translation_placeholders["title"] == "Device" + + +@pytest.mark.usefixtures("mock_homewizardenergy") +async def test_load_creates_repair_issue_when_name_is_updated( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_homewizardenergy: MagicMock, +) -> None: + """Test setup creates repair issue for v2 API upgrade and updates title when device name changes.""" + mock_config_entry.add_to_hass(hass) + await hass.async_block_till_done() + + with patch("homeassistant.components.homewizard.has_v2_api", return_value=True): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + + await hass.async_block_till_done() + + issue_registry = ir.async_get(hass) + issue_id = f"migrate_to_v2_api_{mock_config_entry.entry_id}" + + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + assert issue is not None + + # Initial title should be "Device" + assert issue.translation_placeholders["title"] == "Device" + + # Update the device name + device_registry = dr.async_get(hass) + device = get_main_device(hass, mock_config_entry) + + # Update device name + device_registry.async_update_device( + device_id=device.id, + name_by_user="My HomeWizard Device", + ) + + # Reload integration to trigger issue update + with patch("homeassistant.components.homewizard.has_v2_api", return_value=True): + await hass.config_entries.async_reload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + issue = issue_registry.async_get_issue(domain=DOMAIN, issue_id=issue_id) + assert issue is not None + + # Title should now reflect updated device name + assert issue.translation_placeholders["title"] == "Device (My HomeWizard Device)" + + @pytest.mark.usefixtures("mock_homewizardenergy") async def test_load_removes_reauth_flow( hass: HomeAssistant, diff --git a/tests/components/matter/snapshots/test_button.ambr b/tests/components/matter/snapshots/test_button.ambr index 0c5b81ba644b2b..92aaa1149140a7 100644 --- a/tests/components/matter/snapshots/test_button.ambr +++ b/tests/components/matter/snapshots/test_button.ambr @@ -2479,6 +2479,54 @@ 'state': 'unknown', }) # --- +# name: test_buttons[silabs_water_heater][button.water_heater_cancel_boost-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'button', + 'entity_category': None, + 'entity_id': 'button.water_heater_cancel_boost', + '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': 'Cancel boost', + 'platform': 'matter', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cancel_boost', + 'unique_id': '00000000000004D2-0000000000000019-MatterNodeDevice-2-WaterHeaterManagementCancelBoost-148-65529', + 'unit_of_measurement': None, + }) +# --- +# name: test_buttons[silabs_water_heater][button.water_heater_cancel_boost-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Water Heater Cancel boost', + }), + 'context': , + 'entity_id': 'button.water_heater_cancel_boost', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- # name: test_buttons[smoke_detector][button.smoke_sensor_identify-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/matter/test_water_heater.py b/tests/components/matter/test_water_heater.py index a674c87c24ba70..9c2def1d08a8c3 100644 --- a/tests/components/matter/test_water_heater.py +++ b/tests/components/matter/test_water_heater.py @@ -8,13 +8,19 @@ import pytest from syrupy.assertion import SnapshotAssertion +from homeassistant.components.matter.services import ( + ATTR_DURATION, + ATTR_EMERGENCY_BOOST, + ATTR_TEMPORARY_SETPOINT, + SERVICE_WATER_HEATER_BOOST, +) from homeassistant.components.water_heater import ( STATE_ECO, STATE_HIGH_DEMAND, STATE_OFF, WaterHeaterEntityFeature, ) -from homeassistant.const import Platform +from homeassistant.const import ATTR_ENTITY_ID, Platform from homeassistant.core import HomeAssistant from homeassistant.helpers import entity_registry as er @@ -270,3 +276,43 @@ async def test_water_heater_turn_on_off( ), value=4, ) + + +@pytest.mark.parametrize("node_fixture", ["silabs_water_heater"]) +async def test_async_boost_actions( + hass: HomeAssistant, + matter_client: MagicMock, + matter_node: MatterNode, +) -> None: + """Test that set boost sends the correct command and updates the device.""" + state = hass.states.get("water_heater.water_heater") + assert state + + # Set boost with duration, emergency_boost, and temporary_setpoint + await hass.services.async_call( + "matter", + SERVICE_WATER_HEATER_BOOST, + { + ATTR_ENTITY_ID: "water_heater.water_heater", + ATTR_DURATION: 60, + ATTR_EMERGENCY_BOOST: True, + ATTR_TEMPORARY_SETPOINT: 55, + }, + blocking=True, + ) + assert matter_client.send_device_command.call_count == 1 + assert matter_client.send_device_command.call_args == call( + node_id=matter_node.node_id, + endpoint_id=2, + command=clusters.WaterHeaterManagement.Commands.Boost( + boostInfo=clusters.WaterHeaterManagement.Structs.WaterHeaterBoostInfoStruct( + duration=60, + oneShot=None, + emergencyBoost=True, + temporarySetpoint=5500, + targetPercentage=None, + targetReheat=None, + ) + ), + ) + matter_client.send_device_command.reset_mock() diff --git a/tests/components/nextdns/test_config_flow.py b/tests/components/nextdns/test_config_flow.py index 1ed5a59a5bf84d..280dcc8336bd02 100644 --- a/tests/components/nextdns/test_config_flow.py +++ b/tests/components/nextdns/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock -from nextdns import ApiError, InvalidApiKeyError +from nextdns import ApiError, InvalidApiKeyError, ProfileInfo import pytest from tenacity import RetryError @@ -154,6 +154,32 @@ async def test_reauth_successful( assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_api_key" + + +async def test_reauth_no_profile( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, +) -> None: + """Test reauthentication flow when the profile is no longer available.""" + await init_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reauth_flow(hass) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + + mock_nextdns_client.profiles = [ + ProfileInfo(id="abcd098", fingerprint="abcd098", name="New Profile") + ] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "profile_not_available" @pytest.mark.parametrize( @@ -196,6 +222,105 @@ async def test_reauth_errors( result["flow_id"], user_input={CONF_API_KEY: "new_api_key"}, ) + await hass.async_block_till_done() assert result["type"] is FlowResultType.ABORT assert result["reason"] == "reauth_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_api_key" + + +async def test_reconfigure_flow( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, +) -> None: + """Test starting a reconfigure flow.""" + await init_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_api_key" + + +@pytest.mark.parametrize( + ("exc", "base_error"), + [ + (ApiError("API Error"), "cannot_connect"), + (InvalidApiKeyError, "invalid_api_key"), + (RetryError("Retry Error"), "cannot_connect"), + (TimeoutError, "cannot_connect"), + (ValueError, "unknown"), + ], +) +async def test_reconfiguration_errors( + hass: HomeAssistant, + exc: Exception, + base_error: str, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, + mock_nextdns: AsyncMock, +) -> None: + """Test reconfigure flow with errors.""" + await init_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_nextdns.create.side_effect = exc + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + + assert result["errors"] == {"base": base_error} + + mock_nextdns.create.side_effect = None + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reconfigure_successful" + assert mock_config_entry.data[CONF_API_KEY] == "new_api_key" + + +async def test_reconfigure_flow_no_profile( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_nextdns_client: AsyncMock, +) -> None: + """Test reconfigure flow when the profile is no longer available.""" + await init_integration(hass, mock_config_entry) + + result = await mock_config_entry.start_reconfigure_flow(hass) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reconfigure" + + mock_nextdns_client.profiles = [ + ProfileInfo(id="abcd098", fingerprint="abcd098", name="New Profile") + ] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + user_input={CONF_API_KEY: "new_api_key"}, + ) + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "profile_not_available" diff --git a/tests/components/shelly/test_devices.py b/tests/components/shelly/test_devices.py index bee8cd7c2c57ef..008002edbc5e29 100644 --- a/tests/components/shelly/test_devices.py +++ b/tests/components/shelly/test_devices.py @@ -12,7 +12,10 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.shelly.const import DOMAIN +from homeassistant.components.shelly.const import ( + DOMAIN, + MODEL_FRANKEVER_IRRIGATION_CONTROLLER, +) from homeassistant.core import HomeAssistant from homeassistant.helpers.device_registry import DeviceRegistry from homeassistant.helpers.entity_registry import EntityRegistry @@ -475,6 +478,63 @@ async def test_shelly_pro_3em_with_emeter_name( assert device_entry.name == "Test name Phase C" +async def test_shelly_fk_06x_with_zone_names( + hass: HomeAssistant, + mock_rpc_device: Mock, + entity_registry: EntityRegistry, + device_registry: DeviceRegistry, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Test Shelly Irrigation controller FK-06X with zone names. + + We should get the main device and 6 subdevices, one subdevice per one zone. + """ + device_fixture = await async_load_json_object_fixture( + hass, "fk-06x_gen3_irrigation.json", DOMAIN + ) + monkeypatch.setattr(mock_rpc_device, "shelly", device_fixture["shelly"]) + monkeypatch.setattr(mock_rpc_device, "status", device_fixture["status"]) + monkeypatch.setattr(mock_rpc_device, "config", device_fixture["config"]) + + await init_integration(hass, gen=3, model=MODEL_FRANKEVER_IRRIGATION_CONTROLLER) + + # Main device + entity_id = "sensor.test_name_average_temperature" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == "Test name" + + # 3 zones with names, 3 with default names + zone_names = [ + "Zone Name 1", + "Zone Name 2", + "Zone Name 3", + "Zone 4", + "Zone 5", + "Zone 6", + ] + + for zone_name in zone_names: + entity_id = f"valve.{zone_name.lower().replace(' ', '_')}" + + state = hass.states.get(entity_id) + assert state + + entry = entity_registry.async_get(entity_id) + assert entry + + device_entry = device_registry.async_get(entry.device_id) + assert device_entry + assert device_entry.name == zone_name + + @pytest.mark.usefixtures("entity_registry_enabled_by_default") async def test_block_channel_with_name( hass: HomeAssistant, diff --git a/tests/components/sunricher_dali_center/__init__.py b/tests/components/sunricher_dali_center/__init__.py new file mode 100644 index 00000000000000..5ff4c7ca609a53 --- /dev/null +++ b/tests/components/sunricher_dali_center/__init__.py @@ -0,0 +1 @@ +"""Tests for the Sunricher DALI Center integration.""" diff --git a/tests/components/sunricher_dali_center/conftest.py b/tests/components/sunricher_dali_center/conftest.py new file mode 100644 index 00000000000000..b533a5e8ded885 --- /dev/null +++ b/tests/components/sunricher_dali_center/conftest.py @@ -0,0 +1,150 @@ +"""Common fixtures for the Dali Center tests.""" + +from collections.abc import Generator +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from homeassistant.components.sunricher_dali_center.const import ( + CONF_SERIAL_NUMBER, + DOMAIN, +) +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) + +from tests.common import MockConfigEntry + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Return the default mocked config entry.""" + return MockConfigEntry( + domain=DOMAIN, + data={ + CONF_SERIAL_NUMBER: "6A242121110E", + CONF_HOST: "192.168.1.100", + CONF_PORT: 1883, + CONF_NAME: "Test Gateway", + CONF_USERNAME: "gateway_user", + CONF_PASSWORD: "gateway_pass", + }, + unique_id="6A242121110E", + title="Test Gateway", + ) + + +def _create_mock_device( + dev_id: str, + dev_type: str, + name: str, + model: str, + color_mode: str, + gw_sn: str = "6A242121110E", +) -> MagicMock: + """Create a mock device with standard attributes.""" + device = MagicMock() + device.dev_id = dev_id + device.unique_id = dev_id + device.status = "online" + device.dev_type = dev_type + device.name = name + device.model = model + device.gw_sn = gw_sn + device.color_mode = color_mode + device.turn_on = MagicMock() + device.turn_off = MagicMock() + device.read_status = MagicMock() + return device + + +@pytest.fixture +def mock_devices() -> list[MagicMock]: + """Return mocked Device objects.""" + return [ + _create_mock_device( + "01010000026A242121110E", + "0101", + "Dimmer 0000-02", + "DALI DT6 Dimmable Driver", + "brightness", + ), + _create_mock_device( + "01020000036A242121110E", + "0102", + "CCT 0000-03", + "DALI DT8 Tc Dimmable Driver", + "color_temp", + ), + _create_mock_device( + "01030000046A242121110E", + "0103", + "HS Color Light", + "DALI HS Color Driver", + "hs", + ), + _create_mock_device( + "01040000056A242121110E", + "0104", + "RGBW Light", + "DALI RGBW Driver", + "rgbw", + ), + _create_mock_device( + "01010000026A242121110E", + "0101", + "Duplicate Dimmer", + "DALI DT6 Dimmable Driver", + "brightness", + ), + ] + + +@pytest.fixture +def mock_discovery(mock_gateway: MagicMock) -> Generator[MagicMock]: + """Mock DaliGatewayDiscovery.""" + with patch( + "homeassistant.components.sunricher_dali_center.config_flow.DaliGatewayDiscovery" + ) as mock_discovery_class: + mock_discovery = mock_discovery_class.return_value + mock_discovery.discover_gateways = AsyncMock(return_value=[mock_gateway]) + yield mock_discovery + + +@pytest.fixture +def mock_gateway(mock_devices: list[MagicMock]) -> Generator[MagicMock]: + """Return a mocked DaliGateway.""" + with ( + patch( + "homeassistant.components.sunricher_dali_center.DaliGateway", autospec=True + ) as mock_gateway_class, + patch( + "homeassistant.components.sunricher_dali_center.config_flow.DaliGateway", + new=mock_gateway_class, + ), + ): + mock_gateway = mock_gateway_class.return_value + mock_gateway.gw_sn = "6A242121110E" + mock_gateway.gw_ip = "192.168.1.100" + mock_gateway.port = 1883 + mock_gateway.name = "Test Gateway" + mock_gateway.username = "gateway_user" + mock_gateway.passwd = "gateway_pass" + mock_gateway.connect = AsyncMock() + mock_gateway.disconnect = AsyncMock() + mock_gateway.discover_devices = AsyncMock(return_value=mock_devices) + yield mock_gateway + + +@pytest.fixture +def mock_setup_entry() -> Generator[AsyncMock]: + """Override async_setup_entry.""" + with patch( + "homeassistant.components.sunricher_dali_center.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry diff --git a/tests/components/sunricher_dali_center/snapshots/test_light.ambr b/tests/components/sunricher_dali_center/snapshots/test_light.ambr new file mode 100644 index 00000000000000..75ee3145121644 --- /dev/null +++ b/tests/components/sunricher_dali_center/snapshots/test_light.ambr @@ -0,0 +1,253 @@ +# serializer version: 1 +# name: test_entities[light.cct_0000_03-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max_color_temp_kelvin': 8000, + 'max_mireds': 1000, + 'min_color_temp_kelvin': 1000, + 'min_mireds': 125, + '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.cct_0000_03', + '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': 'sunricher_dali_center', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01020000036A242121110E', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.cct_0000_03-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'color_temp': None, + 'color_temp_kelvin': None, + 'friendly_name': 'CCT 0000-03', + 'hs_color': None, + 'max_color_temp_kelvin': 8000, + 'max_mireds': 1000, + 'min_color_temp_kelvin': 1000, + 'min_mireds': 125, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.cct_0000_03', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[light.dimmer_0000_02-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.dimmer_0000_02', + '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': 'sunricher_dali_center', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01010000026A242121110E', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.dimmer_0000_02-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'Dimmer 0000-02', + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + }), + 'context': , + 'entity_id': 'light.dimmer_0000_02', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[light.hs_color_light-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.hs_color_light', + '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': 'sunricher_dali_center', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01030000046A242121110E', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.hs_color_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'HS Color Light', + 'hs_color': None, + 'rgb_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.hs_color_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- +# name: test_entities[light.rgbw_light-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.rgbw_light', + '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': 'sunricher_dali_center', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': None, + 'unique_id': '01040000056A242121110E', + 'unit_of_measurement': None, + }) +# --- +# name: test_entities[light.rgbw_light-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'brightness': None, + 'color_mode': None, + 'friendly_name': 'RGBW Light', + 'hs_color': None, + 'rgb_color': None, + 'rgbw_color': None, + 'supported_color_modes': list([ + , + ]), + 'supported_features': , + 'xy_color': None, + }), + 'context': , + 'entity_id': 'light.rgbw_light', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unknown', + }) +# --- diff --git a/tests/components/sunricher_dali_center/test_config_flow.py b/tests/components/sunricher_dali_center/test_config_flow.py new file mode 100644 index 00000000000000..dbad0c11f5ef15 --- /dev/null +++ b/tests/components/sunricher_dali_center/test_config_flow.py @@ -0,0 +1,224 @@ +"""Test the DALI Center config flow.""" + +from unittest.mock import AsyncMock, MagicMock + +from PySrDaliGateway.exceptions import DaliGatewayError + +from homeassistant.components.sunricher_dali_center.const import ( + CONF_SERIAL_NUMBER, + DOMAIN, +) +from homeassistant.config_entries import SOURCE_USER +from homeassistant.const import ( + CONF_HOST, + CONF_NAME, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from tests.common import MockConfigEntry + + +async def test_discovery_flow_success( + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_discovery: MagicMock, + mock_gateway: MagicMock, +) -> None: + """Test a successful discovery flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "user" + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"selected_gateway": mock_gateway.gw_sn}, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + assert result.get("title") == mock_gateway.name + assert result.get("data") == { + CONF_SERIAL_NUMBER: mock_gateway.gw_sn, + CONF_HOST: mock_gateway.gw_ip, + CONF_PORT: mock_gateway.port, + CONF_NAME: mock_gateway.name, + CONF_USERNAME: mock_gateway.username, + CONF_PASSWORD: mock_gateway.passwd, + } + result_entry = result.get("result") + assert result_entry is not None + assert result_entry.unique_id == mock_gateway.gw_sn + mock_setup_entry.assert_called_once() + mock_gateway.connect.assert_awaited_once() + mock_gateway.disconnect.assert_awaited_once() + + +async def test_discovery_no_gateways_found( + hass: HomeAssistant, + mock_discovery: MagicMock, + mock_gateway: MagicMock, +) -> None: + """Test discovery step when no gateways are found.""" + mock_discovery.discover_gateways.return_value = [] + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + errors = result.get("errors") + assert errors is not None + assert errors["base"] == "no_devices_found" + + mock_discovery.discover_gateways.return_value = [mock_gateway] + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"selected_gateway": mock_gateway.gw_sn}, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_discovery_gateway_error( + hass: HomeAssistant, + mock_discovery: MagicMock, + mock_gateway: MagicMock, +) -> None: + """Test discovery error handling when gateway search fails.""" + mock_discovery.discover_gateways.side_effect = DaliGatewayError("failure") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + errors = result.get("errors") + assert errors is not None + assert errors["base"] == "discovery_failed" + + mock_discovery.discover_gateways.side_effect = None + mock_discovery.discover_gateways.return_value = [mock_gateway] + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"selected_gateway": mock_gateway.gw_sn}, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_discovery_connection_failure( + hass: HomeAssistant, + mock_discovery: MagicMock, + mock_gateway: MagicMock, +) -> None: + """Test connection failure when validating the selected gateway.""" + mock_gateway.connect.side_effect = DaliGatewayError("failure") + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"selected_gateway": mock_gateway.gw_sn}, + ) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + errors = result.get("errors") + assert errors is not None + assert errors["base"] == "cannot_connect" + mock_gateway.connect.assert_awaited_once() + mock_gateway.disconnect.assert_not_awaited() + + mock_gateway.connect.side_effect = None + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"selected_gateway": mock_gateway.gw_sn}, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_discovery_duplicate_filtered( + hass: HomeAssistant, + mock_discovery: MagicMock, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, +) -> None: + """Test that already configured gateways are filtered out.""" + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + errors = result.get("errors") + assert errors is not None + assert errors["base"] == "no_devices_found" + + await hass.config_entries.async_remove(mock_config_entry.entry_id) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + assert result.get("type") is FlowResultType.FORM + assert result.get("step_id") == "select_gateway" + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"selected_gateway": mock_gateway.gw_sn}, + ) + + assert result.get("type") is FlowResultType.CREATE_ENTRY + + +async def test_discovery_unique_id_already_configured( + hass: HomeAssistant, + mock_discovery: MagicMock, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, +) -> None: + """Test duplicate protection when the entry appears during the flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": SOURCE_USER} + ) + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + + mock_config_entry.add_to_hass(hass) + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"selected_gateway": mock_gateway.gw_sn}, + ) + + assert result.get("type") is FlowResultType.ABORT + assert result.get("reason") == "already_configured" diff --git a/tests/components/sunricher_dali_center/test_init.py b/tests/components/sunricher_dali_center/test_init.py new file mode 100644 index 00000000000000..20ff81ce059053 --- /dev/null +++ b/tests/components/sunricher_dali_center/test_init.py @@ -0,0 +1,61 @@ +"""Test the Dali Center integration initialization.""" + +from unittest.mock import MagicMock + +from PySrDaliGateway.exceptions import DaliGatewayError + +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_setup_entry_success( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, +) -> None: + """Test successful setup of config entry.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + mock_gateway.connect.assert_called_once() + + +async def test_setup_entry_connection_error( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, +) -> None: + """Test setup fails when gateway connection fails.""" + mock_config_entry.add_to_hass(hass) + mock_gateway.connect.side_effect = DaliGatewayError("Connection failed") + + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY + mock_gateway.connect.assert_called_once() + + +async def test_unload_entry( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, +) -> None: + """Test successful unloading of config entry.""" + mock_config_entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(mock_config_entry.entry_id) + await hass.async_block_till_done() + + assert mock_config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/sunricher_dali_center/test_light.py b/tests/components/sunricher_dali_center/test_light.py new file mode 100644 index 00000000000000..999c3f8d6df53b --- /dev/null +++ b/tests/components/sunricher_dali_center/test_light.py @@ -0,0 +1,177 @@ +"""Test the Dali Center light platform.""" + +from typing import Any +from unittest.mock import MagicMock, patch + +import pytest + +from homeassistant.components.light import ( + ATTR_BRIGHTNESS, + DOMAIN as LIGHT_DOMAIN, + SERVICE_TURN_OFF, + SERVICE_TURN_ON, +) +from homeassistant.const import ATTR_ENTITY_ID, Platform +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from tests.common import MockConfigEntry, SnapshotAssertion, snapshot_platform + +TEST_DIMMER_ENTITY_ID = "light.dimmer_0000_02" +TEST_DIMMER_DEVICE_ID = "01010000026A242121110E" +TEST_CCT_DEVICE_ID = "01020000036A242121110E" +TEST_HS_DEVICE_ID = "01030000046A242121110E" +TEST_RGBW_DEVICE_ID = "01040000056A242121110E" + + +def _dispatch_status( + gateway: MagicMock, device_id: str, status: dict[str, Any] +) -> None: + """Invoke the status callback registered on the gateway mock.""" + callback = gateway.on_light_status + assert callable(callback) + callback(device_id, status) + + +@pytest.fixture +def platforms() -> list[Platform]: + """Fixture to specify which platforms to test.""" + return [Platform.LIGHT] + + +@pytest.fixture +async def init_integration( + hass: HomeAssistant, + mock_config_entry: MockConfigEntry, + mock_gateway: MagicMock, + mock_devices: list[MagicMock], + platforms: list[Platform], +) -> MockConfigEntry: + """Set up the integration for testing.""" + mock_config_entry.add_to_hass(hass) + + with patch("homeassistant.components.sunricher_dali_center._PLATFORMS", platforms): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + return mock_config_entry + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default", "init_integration") +async def test_entities( + hass: HomeAssistant, + snapshot: SnapshotAssertion, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the light entities.""" + await snapshot_platform(hass, entity_registry, snapshot, mock_config_entry.entry_id) + + device_entries = dr.async_entries_for_config_entry( + device_registry, mock_config_entry.entry_id + ) + assert len(device_entries) == 5 + + entity_entries = er.async_entries_for_config_entry( + entity_registry, mock_config_entry.entry_id + ) + for entity_entry in entity_entries: + assert entity_entry.device_id is not None + + +async def test_turn_on_light( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_devices: list[MagicMock], +) -> None: + """Test turning on a light.""" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_DIMMER_ENTITY_ID}, + blocking=True, + ) + + mock_devices[0].turn_on.assert_called_once() + + +async def test_turn_off_light( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_devices: list[MagicMock], +) -> None: + """Test turning off a light.""" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_OFF, + {ATTR_ENTITY_ID: TEST_DIMMER_ENTITY_ID}, + blocking=True, + ) + + mock_devices[0].turn_off.assert_called_once() + + +async def test_turn_on_with_brightness( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_devices: list[MagicMock], +) -> None: + """Test turning on light with brightness.""" + await hass.services.async_call( + LIGHT_DOMAIN, + SERVICE_TURN_ON, + {ATTR_ENTITY_ID: TEST_DIMMER_ENTITY_ID, ATTR_BRIGHTNESS: 128}, + blocking=True, + ) + + mock_devices[0].turn_on.assert_called_once_with( + brightness=128, + color_temp_kelvin=None, + hs_color=None, + rgbw_color=None, + ) + + +async def test_dispatcher_connection( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_devices: list[MagicMock], + mock_gateway: MagicMock, +) -> None: + """Test that dispatcher signals are properly connected.""" + entity_entry = er.async_get(hass).async_get(TEST_DIMMER_ENTITY_ID) + assert entity_entry is not None + + state = hass.states.get(TEST_DIMMER_ENTITY_ID) + assert state is not None + + status_update: dict[str, Any] = {"is_on": True, "brightness": 128} + + _dispatch_status(mock_gateway, TEST_DIMMER_DEVICE_ID, status_update) + await hass.async_block_till_done() + + state_after = hass.states.get(TEST_DIMMER_ENTITY_ID) + assert state_after is not None + + +@pytest.mark.parametrize( + ("device_id", "status_update"), + [ + (TEST_CCT_DEVICE_ID, {"color_temp_kelvin": 3000}), + (TEST_HS_DEVICE_ID, {"hs_color": (120.0, 50.0)}), + (TEST_RGBW_DEVICE_ID, {"rgbw_color": (255, 128, 64, 32)}), + (TEST_RGBW_DEVICE_ID, {"white_level": 200}), + ], + ids=["cct_color_temp", "hs_color", "rgbw_color", "rgbw_white_level"], +) +async def test_status_updates( + hass: HomeAssistant, + init_integration: MockConfigEntry, + mock_gateway: MagicMock, + device_id: str, + status_update: dict[str, Any], +) -> None: + """Test various status updates for different device types.""" + _dispatch_status(mock_gateway, device_id, status_update) + await hass.async_block_till_done() diff --git a/tests/components/switchbot/__init__.py b/tests/components/switchbot/__init__.py index 9fc401270fbaeb..4a9fcd772b20c7 100644 --- a/tests/components/switchbot/__init__.py +++ b/tests/components/switchbot/__init__.py @@ -1171,3 +1171,32 @@ def make_advertisement( connectable=True, tx_power=-127, ) + + +CLIMATE_PANEL_SERVICE_INFO = BluetoothServiceInfoBleak( + name="Climate Panel", + manufacturer_data={ + 2409: b"\xb0\xe9\xfe\x8e\x98Oi_\x06\x9a,\x00\x00\x00\x00\xe4\x00\x08\x04\x00\x01\x00\x00" + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00 _\x00\x10\xf3\xd8@", + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + address="AA:BB:CC:DD:EE:FF", + rssi=-60, + source="local", + advertisement=generate_advertisement_data( + local_name="Climate Panel", + manufacturer_data={ + 2409: b"\xb0\xe9\xfe\x8e\x98Oi_\x06\x9a,\x00\x00\x00\x00\xe4\x00\x08\x04\x00\x01\x00\x00" + }, + service_data={ + "0000fd3d-0000-1000-8000-00805f9b34fb": b"\x00 _\x00\x10\xf3\xd8@" + }, + service_uuids=["cba20d00-224d-11e6-9fb8-0002a5d5c51b"], + ), + device=generate_ble_device("AA:BB:CC:DD:EE:FF", "Climate Panel"), + time=0, + connectable=True, + tx_power=-127, +) diff --git a/tests/components/switchbot/test_sensor.py b/tests/components/switchbot/test_sensor.py index 0e46324076696d..b38a4e43fbfd63 100644 --- a/tests/components/switchbot/test_sensor.py +++ b/tests/components/switchbot/test_sensor.py @@ -24,6 +24,7 @@ from . import ( CIRCULATOR_FAN_SERVICE_INFO, + CLIMATE_PANEL_SERVICE_INFO, EVAPORATIVE_HUMIDIFIER_SERVICE_INFO, HUB3_SERVICE_INFO, HUBMINI_MATTER_SERVICE_INFO, @@ -728,3 +729,66 @@ async def test_relay_switch_2pm_sensor(hass: HomeAssistant) -> None: assert await hass.config_entries.async_unload(entry.entry_id) await hass.async_block_till_done() + + +@pytest.mark.usefixtures("entity_registry_enabled_by_default") +async def test_climate_panel_sensor(hass: HomeAssistant) -> None: + """Test setting up creates the sensor for Climate Panel.""" + await async_setup_component(hass, DOMAIN, {}) + inject_bluetooth_service_info(hass, CLIMATE_PANEL_SERVICE_INFO) + + entry = MockConfigEntry( + domain=DOMAIN, + data={ + CONF_ADDRESS: "AA:BB:CC:DD:EE:FF", + CONF_NAME: "test-name", + CONF_SENSOR_TYPE: "climate_panel", + }, + unique_id="aabbccddeeff", + ) + entry.add_to_hass(hass) + + assert await hass.config_entries.async_setup(entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.states.async_all("sensor")) == 4 + assert len(hass.states.async_all("binary_sensor")) == 2 + + temperature_sensor = hass.states.get("sensor.test_name_temperature") + temperature_sensor_attrs = temperature_sensor.attributes + assert temperature_sensor.state == "26.6" + assert temperature_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Temperature" + assert temperature_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "°C" + assert temperature_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + humidity_sensor = hass.states.get("sensor.test_name_humidity") + humidity_sensor_attrs = humidity_sensor.attributes + assert humidity_sensor.state == "44" + assert humidity_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Humidity" + assert humidity_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "%" + assert humidity_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + rssi_sensor = hass.states.get("sensor.test_name_bluetooth_signal") + rssi_sensor_attrs = rssi_sensor.attributes + assert rssi_sensor.state == "-60" + assert rssi_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Bluetooth signal" + assert rssi_sensor_attrs[ATTR_UNIT_OF_MEASUREMENT] == "dBm" + + battery_sensor = hass.states.get("sensor.test_name_battery") + battery_sensor_attrs = battery_sensor.attributes + assert battery_sensor.state == "95" + assert battery_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Battery" + assert battery_sensor_attrs[ATTR_STATE_CLASS] == "measurement" + + light_sensor = hass.states.get("binary_sensor.test_name_light") + light_sensor_attrs = light_sensor.attributes + assert light_sensor.state == "off" + assert light_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Light" + + motion_sensor = hass.states.get("binary_sensor.test_name_motion") + motion_sensor_attrs = motion_sensor.attributes + assert motion_sensor.state == "on" + assert motion_sensor_attrs[ATTR_FRIENDLY_NAME] == "test-name Motion" + + assert await hass.config_entries.async_unload(entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/telegram_bot/conftest.py b/tests/components/telegram_bot/conftest.py index 8220e50d8745ad..b1b02ebd33bb40 100644 --- a/tests/components/telegram_bot/conftest.py +++ b/tests/components/telegram_bot/conftest.py @@ -6,7 +6,15 @@ from unittest.mock import AsyncMock, patch import pytest -from telegram import Bot, Chat, ChatFullInfo, Message, User, WebhookInfo +from telegram import ( + AcceptedGiftTypes, + Bot, + Chat, + ChatFullInfo, + Message, + User, + WebhookInfo, +) from telegram.constants import AccentColor, ChatType from homeassistant.components.telegram_bot import ( @@ -106,6 +114,7 @@ def mock_external_calls() -> Generator[None]: type="PRIVATE", max_reaction_count=100, accent_color_id=AccentColor.COLOR_000, + accepted_gift_types=AcceptedGiftTypes(True, True, True, True), ) test_user = User(123456, "Testbot", True, "mock last name", "mock username") message = Message( diff --git a/tests/components/telegram_bot/test_config_flow.py b/tests/components/telegram_bot/test_config_flow.py index 0886246b7e1958..051fd03ca12a28 100644 --- a/tests/components/telegram_bot/test_config_flow.py +++ b/tests/components/telegram_bot/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import AsyncMock, patch -from telegram import ChatFullInfo, User +from telegram import AcceptedGiftTypes, ChatFullInfo, User from telegram.constants import AccentColor from telegram.error import BadRequest, InvalidToken, NetworkError @@ -390,6 +390,7 @@ async def test_subentry_flow( type="PRIVATE", max_reaction_count=100, accent_color_id=AccentColor.COLOR_000, + accepted_gift_types=AcceptedGiftTypes(True, True, True, True), ), ): result = await hass.config_entries.subentries.async_configure( @@ -458,6 +459,7 @@ async def test_subentry_flow_chat_error( type="PRIVATE", max_reaction_count=100, accent_color_id=AccentColor.COLOR_000, + accepted_gift_types=AcceptedGiftTypes(True, True, True, True), ), ): result = await hass.config_entries.subentries.async_configure( @@ -534,6 +536,7 @@ async def test_import_multiple( type="PRIVATE", max_reaction_count=100, accent_color_id=AccentColor.COLOR_000, + accepted_gift_types=AcceptedGiftTypes(True, True, True, True), ), ), ):