From 0aeff366bdb397983e7cde1ce69a54a057f2ecde Mon Sep 17 00:00:00 2001 From: tronikos Date: Wed, 6 Aug 2025 02:32:42 -0700 Subject: [PATCH 01/17] Fix PG&E and Duquesne Light Company in Opower (#149658) Co-authored-by: Norbert Rittel --- .../components/opower/config_flow.py | 216 ++++++---- homeassistant/components/opower/const.py | 1 + .../components/opower/coordinator.py | 7 +- homeassistant/components/opower/manifest.json | 2 +- homeassistant/components/opower/strings.json | 49 ++- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- tests/components/opower/test_config_flow.py | 408 +++++++++++++++--- 8 files changed, 538 insertions(+), 149 deletions(-) diff --git a/homeassistant/components/opower/config_flow.py b/homeassistant/components/opower/config_flow.py index e7f2534e1adb3..b66c4c6870e5d 100644 --- a/homeassistant/components/opower/config_flow.py +++ b/homeassistant/components/opower/config_flow.py @@ -9,6 +9,8 @@ from opower import ( CannotConnect, InvalidAuth, + MfaChallenge, + MfaHandlerBase, Opower, create_cookie_jar, get_supported_utility_names, @@ -16,49 +18,34 @@ ) import voluptuous as vol -from homeassistant.config_entries import ConfigFlow, ConfigFlowResult +from homeassistant.config_entries import SOURCE_REAUTH, ConfigFlow, ConfigFlowResult from homeassistant.const import CONF_NAME, CONF_PASSWORD, CONF_USERNAME from homeassistant.core import HomeAssistant, callback from homeassistant.helpers.aiohttp_client import async_create_clientsession from homeassistant.helpers.typing import VolDictType -from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN +from .const import CONF_LOGIN_DATA, CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN _LOGGER = logging.getLogger(__name__) - -STEP_USER_DATA_SCHEMA = vol.Schema( - { - vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names()), - vol.Required(CONF_USERNAME): str, - vol.Required(CONF_PASSWORD): str, - } -) +CONF_MFA_CODE = "mfa_code" +CONF_MFA_METHOD = "mfa_method" async def _validate_login( - hass: HomeAssistant, login_data: dict[str, str] -) -> dict[str, str]: - """Validate login data and return any errors.""" + hass: HomeAssistant, + data: Mapping[str, Any], +) -> None: + """Validate login data and raise exceptions on failure.""" api = Opower( async_create_clientsession(hass, cookie_jar=create_cookie_jar()), - login_data[CONF_UTILITY], - login_data[CONF_USERNAME], - login_data[CONF_PASSWORD], - login_data.get(CONF_TOTP_SECRET), + data[CONF_UTILITY], + data[CONF_USERNAME], + data[CONF_PASSWORD], + data.get(CONF_TOTP_SECRET), + data.get(CONF_LOGIN_DATA), ) - errors: dict[str, str] = {} - try: - await api.async_login() - except InvalidAuth: - _LOGGER.exception( - "Invalid auth when connecting to %s", login_data[CONF_UTILITY] - ) - errors["base"] = "invalid_auth" - except CannotConnect: - _LOGGER.exception("Could not connect to %s", login_data[CONF_UTILITY]) - errors["base"] = "cannot_connect" - return errors + await api.async_login() class OpowerConfigFlow(ConfigFlow, domain=DOMAIN): @@ -68,81 +55,147 @@ class OpowerConfigFlow(ConfigFlow, domain=DOMAIN): def __init__(self) -> None: """Initialize a new OpowerConfigFlow.""" - self.utility_info: dict[str, Any] | None = None + self._data: dict[str, Any] = {} + self.mfa_handler: MfaHandlerBase | None = None async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle the initial step.""" + """Handle the initial step (select utility).""" + if user_input is not None: + self._data[CONF_UTILITY] = user_input[CONF_UTILITY] + return await self.async_step_credentials() + + return self.async_show_form( + step_id="user", + data_schema=vol.Schema( + {vol.Required(CONF_UTILITY): vol.In(get_supported_utility_names())} + ), + ) + + async def async_step_credentials( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle credentials step.""" errors: dict[str, str] = {} + utility = select_utility(self._data[CONF_UTILITY]) + if user_input is not None: + self._data.update(user_input) + self._async_abort_entries_match( { - CONF_UTILITY: user_input[CONF_UTILITY], - CONF_USERNAME: user_input[CONF_USERNAME], + CONF_UTILITY: self._data[CONF_UTILITY], + CONF_USERNAME: self._data[CONF_USERNAME], } ) - if select_utility(user_input[CONF_UTILITY]).accepts_mfa(): - self.utility_info = user_input - return await self.async_step_mfa() - errors = await _validate_login(self.hass, user_input) - if not errors: - return self._async_create_opower_entry(user_input) - else: - user_input = {} - user_input.pop(CONF_PASSWORD, None) + try: + await _validate_login(self.hass, self._data) + except MfaChallenge as exc: + self.mfa_handler = exc.handler + return await self.async_step_mfa_options() + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + else: + return self._async_create_opower_entry(self._data) + + schema_dict: VolDictType = { + vol.Required(CONF_USERNAME): str, + vol.Required(CONF_PASSWORD): str, + } + if utility.accepts_totp_secret(): + schema_dict[vol.Optional(CONF_TOTP_SECRET)] = str + return self.async_show_form( - step_id="user", + step_id="credentials", data_schema=self.add_suggested_values_to_schema( - STEP_USER_DATA_SCHEMA, user_input + vol.Schema(schema_dict), user_input ), errors=errors, ) - async def async_step_mfa( + async def async_step_mfa_options( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: - """Handle MFA step.""" - assert self.utility_info is not None + """Handle MFA options step.""" errors: dict[str, str] = {} + assert self.mfa_handler is not None + if user_input is not None: - data = {**self.utility_info, **user_input} - errors = await _validate_login(self.hass, data) - if not errors: - return self._async_create_opower_entry(data) - - if errors: - schema = { - vol.Required( - CONF_USERNAME, default=self.utility_info[CONF_USERNAME] - ): str, - vol.Required(CONF_PASSWORD): str, - } - else: - schema = {} + method = user_input[CONF_MFA_METHOD] + try: + await self.mfa_handler.async_select_mfa_option(method) + except CannotConnect: + errors["base"] = "cannot_connect" + else: + return await self.async_step_mfa_code() + + mfa_options = await self.mfa_handler.async_get_mfa_options() + if not mfa_options: + return await self.async_step_mfa_code() + return self.async_show_form( + step_id="mfa_options", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_MFA_METHOD): vol.In(mfa_options)}), + user_input, + ), + errors=errors, + ) - schema[vol.Required(CONF_TOTP_SECRET)] = str + async def async_step_mfa_code( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle MFA code submission step.""" + assert self.mfa_handler is not None + errors: dict[str, str] = {} + if user_input is not None: + code = user_input[CONF_MFA_CODE] + try: + login_data = await self.mfa_handler.async_submit_mfa_code(code) + except InvalidAuth: + errors["base"] = "invalid_mfa_code" + except CannotConnect: + errors["base"] = "cannot_connect" + else: + self._data[CONF_LOGIN_DATA] = login_data + if self.source == SOURCE_REAUTH: + return self.async_update_reload_and_abort( + self._get_reauth_entry(), data=self._data + ) + return self._async_create_opower_entry(self._data) return self.async_show_form( - step_id="mfa", - data_schema=vol.Schema(schema), + step_id="mfa_code", + data_schema=self.add_suggested_values_to_schema( + vol.Schema({vol.Required(CONF_MFA_CODE): str}), user_input + ), errors=errors, ) @callback - def _async_create_opower_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + def _async_create_opower_entry( + self, data: dict[str, Any], **kwargs: Any + ) -> ConfigFlowResult: """Create the config entry.""" return self.async_create_entry( title=f"{data[CONF_UTILITY]} ({data[CONF_USERNAME]})", data=data, + **kwargs, ) async def async_step_reauth( self, entry_data: Mapping[str, Any] ) -> ConfigFlowResult: """Handle configuration by re-auth.""" - return await self.async_step_reauth_confirm() + reauth_entry = self._get_reauth_entry() + self._data = dict(reauth_entry.data) + return self.async_show_form( + step_id="reauth_confirm", + description_placeholders={CONF_NAME: reauth_entry.title}, + ) async def async_step_reauth_confirm( self, user_input: dict[str, Any] | None = None @@ -150,21 +203,34 @@ async def async_step_reauth_confirm( """Dialog that informs the user that reauth is required.""" errors: dict[str, str] = {} reauth_entry = self._get_reauth_entry() + if user_input is not None: - data = {**reauth_entry.data, **user_input} - errors = await _validate_login(self.hass, data) - if not errors: - return self.async_update_reload_and_abort(reauth_entry, data=data) + self._data.update(user_input) + try: + await _validate_login(self.hass, self._data) + except MfaChallenge as exc: + self.mfa_handler = exc.handler + return await self.async_step_mfa_options() + except InvalidAuth: + errors["base"] = "invalid_auth" + except CannotConnect: + errors["base"] = "cannot_connect" + else: + return self.async_update_reload_and_abort(reauth_entry, data=self._data) - schema: VolDictType = { - vol.Required(CONF_USERNAME): reauth_entry.data[CONF_USERNAME], + utility = select_utility(self._data[CONF_UTILITY]) + schema_dict: VolDictType = { + vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str, } - if select_utility(reauth_entry.data[CONF_UTILITY]).accepts_mfa(): - schema[vol.Optional(CONF_TOTP_SECRET)] = str + if utility.accepts_totp_secret(): + schema_dict[vol.Optional(CONF_TOTP_SECRET)] = str + return self.async_show_form( step_id="reauth_confirm", - data_schema=vol.Schema(schema), + data_schema=self.add_suggested_values_to_schema( + vol.Schema(schema_dict), self._data + ), errors=errors, description_placeholders={CONF_NAME: reauth_entry.title}, ) diff --git a/homeassistant/components/opower/const.py b/homeassistant/components/opower/const.py index c07d41bbdcf76..5da50b2b06f29 100644 --- a/homeassistant/components/opower/const.py +++ b/homeassistant/components/opower/const.py @@ -4,3 +4,4 @@ CONF_UTILITY = "utility" CONF_TOTP_SECRET = "totp_secret" +CONF_LOGIN_DATA = "login_data" diff --git a/homeassistant/components/opower/coordinator.py b/homeassistant/components/opower/coordinator.py index 189fa185cd17e..e6fbbee0bb634 100644 --- a/homeassistant/components/opower/coordinator.py +++ b/homeassistant/components/opower/coordinator.py @@ -14,7 +14,7 @@ ReadResolution, create_cookie_jar, ) -from opower.exceptions import ApiException, CannotConnect, InvalidAuth +from opower.exceptions import ApiException, CannotConnect, InvalidAuth, MfaChallenge from homeassistant.components.recorder import get_instance from homeassistant.components.recorder.models import ( @@ -36,7 +36,7 @@ from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed from homeassistant.util import dt as dt_util -from .const import CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN +from .const import CONF_LOGIN_DATA, CONF_TOTP_SECRET, CONF_UTILITY, DOMAIN _LOGGER = logging.getLogger(__name__) @@ -69,6 +69,7 @@ def __init__( config_entry.data[CONF_USERNAME], config_entry.data[CONF_PASSWORD], config_entry.data.get(CONF_TOTP_SECRET), + config_entry.data.get(CONF_LOGIN_DATA), ) @callback @@ -90,7 +91,7 @@ async def _async_update_data( # Given the infrequent updating (every 12h) # assume previous session has expired and re-login. await self.api.async_login() - except InvalidAuth as err: + except (InvalidAuth, MfaChallenge) as err: _LOGGER.error("Error during login: %s", err) raise ConfigEntryAuthFailed from err except CannotConnect as err: diff --git a/homeassistant/components/opower/manifest.json b/homeassistant/components/opower/manifest.json index 4e88c5a68ccaa..a10c5b2d15d4a 100644 --- a/homeassistant/components/opower/manifest.json +++ b/homeassistant/components/opower/manifest.json @@ -7,5 +7,5 @@ "documentation": "https://www.home-assistant.io/integrations/opower", "iot_class": "cloud_polling", "loggers": ["opower"], - "requirements": ["opower==0.12.4"] + "requirements": ["opower==0.15.1"] } diff --git a/homeassistant/components/opower/strings.json b/homeassistant/components/opower/strings.json index 8d8cecff9057d..5bb2269922023 100644 --- a/homeassistant/components/opower/strings.json +++ b/homeassistant/components/opower/strings.json @@ -3,27 +3,43 @@ "step": { "user": { "data": { - "utility": "Utility name", - "username": "[%key:common::config_flow::data::username%]", - "password": "[%key:common::config_flow::data::password%]" + "utility": "Utility name" }, "data_description": { - "utility": "The name of your utility provider", - "username": "The username for your utility account", - "password": "The password for your utility account" + "utility": "The name of your utility provider" } }, - "mfa": { - "description": "The TOTP secret below is not one of the 6-digit time-based numeric codes. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation.", + "credentials": { + "title": "Enter Credentials", "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", "totp_secret": "TOTP secret" }, "data_description": { - "username": "[%key:component::opower::config::step::user::data_description::username%]", - "password": "[%key:component::opower::config::step::user::data_description::password%]", - "totp_secret": "The TOTP secret for your utility account, used for multi-factor authentication (MFA)." + "username": "The username for your utility account", + "password": "The password for your utility account", + "totp_secret": "This is not a 6-digit code. It is a string of around 16 characters containing the shared secret that enables your authenticator app to generate the correct time-based code at the appropriate time. See the documentation." + } + }, + "mfa_options": { + "title": "Multi-factor authentication", + "description": "Your account requires multi-factor authentication (MFA). Select a method to receive your security code.", + "data": { + "mfa_method": "MFA method" + }, + "data_description": { + "mfa_method": "How to receive your security code" + } + }, + "mfa_code": { + "title": "Enter security code", + "description": "A security code has been sent via your selected method. Please enter it below to complete login.", + "data": { + "mfa_code": "Security code" + }, + "data_description": { + "mfa_code": "Typically a 6-digit code" } }, "reauth_confirm": { @@ -31,18 +47,19 @@ "data": { "username": "[%key:common::config_flow::data::username%]", "password": "[%key:common::config_flow::data::password%]", - "totp_secret": "[%key:component::opower::config::step::mfa::data::totp_secret%]" + "totp_secret": "[%key:component::opower::config::step::credentials::data::totp_secret%]" }, "data_description": { - "username": "[%key:component::opower::config::step::user::data_description::username%]", - "password": "[%key:component::opower::config::step::user::data_description::password%]", - "totp_secret": "The TOTP secret for your utility account, used for multi-factor authentication (MFA)." + "username": "[%key:component::opower::config::step::credentials::data_description::username%]", + "password": "[%key:component::opower::config::step::credentials::data_description::password%]", + "totp_secret": "[%key:component::opower::config::step::credentials::data_description::totp_secret%]" } } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", - "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]" + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "invalid_mfa_code": "The security code is incorrect. Please try again." }, "abort": { "already_configured": "[%key:common::config_flow::abort::already_configured_service%]", diff --git a/requirements_all.txt b/requirements_all.txt index dd1421d514c38..eb2916a4fb980 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1628,7 +1628,7 @@ openwrt-luci-rpc==1.1.17 openwrt-ubus-rpc==0.0.2 # homeassistant.components.opower -opower==0.12.4 +opower==0.15.1 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f0cbd32749e43..7cc3f6b67ab51 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1384,7 +1384,7 @@ openhomedevice==2.2.0 openwebifpy==4.3.1 # homeassistant.components.opower -opower==0.12.4 +opower==0.15.1 # homeassistant.components.oralb oralb-ble==0.17.6 diff --git a/tests/components/opower/test_config_flow.py b/tests/components/opower/test_config_flow.py index c9edfc6808f19..4e5c3457fa6c8 100644 --- a/tests/components/opower/test_config_flow.py +++ b/tests/components/opower/test_config_flow.py @@ -3,7 +3,7 @@ from collections.abc import Generator from unittest.mock import AsyncMock, patch -from opower import CannotConnect, InvalidAuth +from opower import CannotConnect, InvalidAuth, MfaChallenge import pytest from homeassistant import config_entries @@ -43,24 +43,32 @@ async def test_form( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert not result["errors"] + assert result["step_id"] == "user" + # Select utility + result2 = await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) + assert result2["type"] is FlowResultType.FORM + assert result2["step_id"] == "credentials" + + # Enter credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: - result2 = await hass.config_entries.flow.async_configure( + result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, ) await hass.async_block_till_done() - assert result2["type"] is FlowResultType.CREATE_ENTRY - assert result2["title"] == "Pacific Gas and Electric Company (PG&E) (test-username)" - assert result2["data"] == { + assert result3["type"] is FlowResultType.CREATE_ENTRY + assert result3["title"] == "Pacific Gas and Electric Company (PG&E) (test-username)" + assert result3["data"] == { "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", @@ -69,33 +77,33 @@ async def test_form( assert mock_login.call_count == 1 -async def test_form_with_mfa( +async def test_form_with_totp( recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test we get the form.""" + """Test we can configure a utility that accepts a TOTP secret.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert not result["errors"] + assert result["step_id"] == "user" + # Select utility result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "utility": "Consolidated Edison (ConEd)", - "username": "test-username", - "password": "test-password", - }, + {"utility": "Consolidated Edison (ConEd)"}, ) assert result2["type"] is FlowResultType.FORM - assert not result2["errors"] + assert result2["step_id"] == "credentials" + # Enter credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { + "username": "test-username", + "password": "test-password", "totp_secret": "test-totp", }, ) @@ -112,43 +120,42 @@ async def test_form_with_mfa( assert mock_login.call_count == 1 -async def test_form_with_mfa_bad_secret( +async def test_form_with_invalid_totp( recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock ) -> None: - """Test MFA asks for password again when validation fails.""" + """Test we handle an invalid TOTP secret.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert not result["errors"] + assert result["step_id"] == "user" result2 = await hass.config_entries.flow.async_configure( result["flow_id"], - { - "utility": "Consolidated Edison (ConEd)", - "username": "test-username", - "password": "test-password", - }, + {"utility": "Consolidated Edison (ConEd)"}, ) assert result2["type"] is FlowResultType.FORM - assert not result2["errors"] + assert result2["step_id"] == "credentials" + # Enter invalid credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", side_effect=InvalidAuth, - ) as mock_login: + ): result3 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "totp_secret": "test-totp", + "username": "test-username", + "password": "test-password", + "totp_secret": "bad-totp", }, ) assert result3["type"] is FlowResultType.FORM - assert result3["errors"] == { - "base": "invalid_auth", - } + assert result3["errors"] == {"base": "invalid_auth"} + assert result3["step_id"] == "credentials" + # Enter valid credentials with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: @@ -157,7 +164,7 @@ async def test_form_with_mfa_bad_secret( { "username": "test-username", "password": "updated-password", - "totp_secret": "updated-totp", + "totp_secret": "good-totp", }, ) @@ -167,26 +174,195 @@ async def test_form_with_mfa_bad_secret( "utility": "Consolidated Edison (ConEd)", "username": "test-username", "password": "updated-password", - "totp_secret": "updated-totp", + "totp_secret": "good-totp", } assert len(mock_setup_entry.mock_calls) == 1 assert mock_login.call_count == 1 +async def test_form_with_mfa_challenge( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test the full interactive MFA flow, including error recovery.""" + # 1. Start the flow and get to the credentials step + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) + + # 2. Trigger an MfaChallenge on login + mock_mfa_handler = AsyncMock() + mock_mfa_handler.async_get_mfa_options.return_value = { + "Email": "fooxxx@mail.com", + "Phone": "xxx-123", + } + mock_mfa_handler.async_submit_mfa_code.return_value = { + "login_data_mock_key": "login_data_mock_value" + } + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=MfaChallenge(message="", handler=mock_mfa_handler), + ) as mock_login: + result_challenge = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + mock_login.assert_awaited_once() + + # 3. Handle the MFA options step, starting with a connection error + assert result_challenge["type"] is FlowResultType.FORM + assert result_challenge["step_id"] == "mfa_options" + mock_mfa_handler.async_get_mfa_options.assert_awaited_once() + + # Test CannotConnect on selecting MFA method + mock_mfa_handler.async_select_mfa_option.side_effect = CannotConnect + result_mfa_connect_fail = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_method": "Email"} + ) + mock_mfa_handler.async_select_mfa_option.assert_awaited_once_with("Email") + assert result_mfa_connect_fail["type"] is FlowResultType.FORM + assert result_mfa_connect_fail["step_id"] == "mfa_options" + assert result_mfa_connect_fail["errors"] == {"base": "cannot_connect"} + + # Retry selecting MFA method successfully + mock_mfa_handler.async_select_mfa_option.side_effect = None + result_mfa_select_ok = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_method": "Email"} + ) + assert mock_mfa_handler.async_select_mfa_option.call_count == 2 + assert result_mfa_select_ok["type"] is FlowResultType.FORM + assert result_mfa_select_ok["step_id"] == "mfa_code" + + # 4. Handle the MFA code step, testing multiple failure scenarios + # Test InvalidAuth on submitting code + mock_mfa_handler.async_submit_mfa_code.side_effect = InvalidAuth + result_mfa_invalid_code = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "bad-code"} + ) + mock_mfa_handler.async_submit_mfa_code.assert_awaited_once_with("bad-code") + assert result_mfa_invalid_code["type"] is FlowResultType.FORM + assert result_mfa_invalid_code["step_id"] == "mfa_code" + assert result_mfa_invalid_code["errors"] == {"base": "invalid_mfa_code"} + + # Test CannotConnect on submitting code + mock_mfa_handler.async_submit_mfa_code.side_effect = CannotConnect + result_mfa_code_connect_fail = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + assert mock_mfa_handler.async_submit_mfa_code.call_count == 2 + mock_mfa_handler.async_submit_mfa_code.assert_called_with("good-code") + assert result_mfa_code_connect_fail["type"] is FlowResultType.FORM + assert result_mfa_code_connect_fail["step_id"] == "mfa_code" + assert result_mfa_code_connect_fail["errors"] == {"base": "cannot_connect"} + + # Retry submitting code successfully + mock_mfa_handler.async_submit_mfa_code.side_effect = None + result_final = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + assert mock_mfa_handler.async_submit_mfa_code.call_count == 3 + mock_mfa_handler.async_submit_mfa_code.assert_called_with("good-code") + + # 5. Verify the flow completes and creates the entry + assert result_final["type"] is FlowResultType.CREATE_ENTRY + assert ( + result_final["title"] + == "Pacific Gas and Electric Company (PG&E) (test-username)" + ) + assert result_final["data"] == { + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username", + "password": "test-password", + "login_data": {"login_data_mock_key": "login_data_mock_value"}, + } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + +async def test_form_with_mfa_challenge_but_no_mfa_options( + recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock +) -> None: + """Test the full interactive MFA flow when there are no MFA options.""" + # 1. Start the flow and get to the credentials step + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) + + # 2. Trigger an MfaChallenge on login + mock_mfa_handler = AsyncMock() + mock_mfa_handler.async_get_mfa_options.return_value = {} + mock_mfa_handler.async_submit_mfa_code.return_value = { + "login_data_mock_key": "login_data_mock_value" + } + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=MfaChallenge(message="", handler=mock_mfa_handler), + ) as mock_login: + result_challenge = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "test-password", + }, + ) + mock_login.assert_awaited_once() + + # 3. No MFA options. Handle the MFA code step + assert result_challenge["type"] is FlowResultType.FORM + assert result_challenge["step_id"] == "mfa_code" + mock_mfa_handler.async_get_mfa_options.assert_awaited_once() + result_final = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + mock_mfa_handler.async_submit_mfa_code.assert_called_with("good-code") + + # 4. Verify the flow completes and creates the entry + assert result_final["type"] is FlowResultType.CREATE_ENTRY + assert ( + result_final["title"] + == "Pacific Gas and Electric Company (PG&E) (test-username)" + ) + assert result_final["data"] == { + "utility": "Pacific Gas and Electric Company (PG&E)", + "username": "test-username", + "password": "test-password", + "login_data": {"login_data_mock_key": "login_data_mock_value"}, + } + await hass.async_block_till_done() + assert len(mock_setup_entry.mock_calls) == 1 + + @pytest.mark.parametrize( ("api_exception", "expected_error"), [ - (InvalidAuth(), "invalid_auth"), - (CannotConnect(), "cannot_connect"), + (InvalidAuth, "invalid_auth"), + (CannotConnect, "cannot_connect"), ], ) async def test_form_exceptions( - recorder_mock: Recorder, hass: HomeAssistant, api_exception, expected_error + recorder_mock: Recorder, + hass: HomeAssistant, + api_exception: Exception, + expected_error: str, ) -> None: """Test we handle exceptions.""" result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) with patch( "homeassistant.components.opower.config_flow.Opower.async_login", @@ -195,7 +371,6 @@ async def test_form_exceptions( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, @@ -203,15 +378,10 @@ async def test_form_exceptions( assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": expected_error} - # On error, the form should have the previous user input, except password, - # as suggested values. + # On error, the form should have the previous user input as suggested values. data_schema = result2["data_schema"].schema - assert ( - get_schema_suggested_value(data_schema, "utility") - == "Pacific Gas and Electric Company (PG&E)" - ) assert get_schema_suggested_value(data_schema, "username") == "test-username" - assert get_schema_suggested_value(data_schema, "password") is None + assert get_schema_suggested_value(data_schema, "password") == "test-password" assert mock_login.call_count == 1 @@ -224,6 +394,10 @@ async def test_form_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) with patch( "homeassistant.components.opower.config_flow.Opower.async_login", @@ -231,7 +405,6 @@ async def test_form_already_configured( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username", "password": "test-password", }, @@ -252,6 +425,10 @@ async def test_form_not_already_configured( result = await hass.config_entries.flow.async_init( DOMAIN, context={"source": config_entries.SOURCE_USER} ) + await hass.config_entries.flow.async_configure( + result["flow_id"], + {"utility": "Pacific Gas and Electric Company (PG&E)"}, + ) with patch( "homeassistant.components.opower.config_flow.Opower.async_login", @@ -259,7 +436,6 @@ async def test_form_not_already_configured( result2 = await hass.config_entries.flow.async_configure( result["flow_id"], { - "utility": "Pacific Gas and Electric Company (PG&E)", "username": "test-username2", "password": "test-password", }, @@ -299,6 +475,16 @@ async def test_form_valid_reauth( assert result["context"]["source"] == "reauth" assert result["context"]["title_placeholders"] == {"name": mock_config_entry.title} + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=InvalidAuth, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["data_schema"].schema.keys() == { + "username", + "password", + } + with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: @@ -321,22 +507,23 @@ async def test_form_valid_reauth( assert mock_login.call_count == 1 -async def test_form_valid_reauth_with_mfa( +async def test_form_valid_reauth_with_totp( recorder_mock: Recorder, hass: HomeAssistant, mock_setup_entry: AsyncMock, mock_unload_entry: AsyncMock, - mock_config_entry: MockConfigEntry, ) -> None: - """Test that we can handle a valid reauth.""" - hass.config_entries.async_update_entry( - mock_config_entry, + """Test that we can handle a valid reauth for a utility with TOTP.""" + mock_config_entry = MockConfigEntry( + title="Consolidated Edison (ConEd) (test-username)", + domain=DOMAIN, data={ - **mock_config_entry.data, - # Requires MFA "utility": "Consolidated Edison (ConEd)", + "username": "test-username", + "password": "test-password", }, ) + mock_config_entry.add_to_hass(hass) mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) hass.config.components.add(DOMAIN) mock_config_entry.async_start_reauth(hass) @@ -346,6 +533,17 @@ async def test_form_valid_reauth_with_mfa( assert len(flows) == 1 result = flows[0] + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=InvalidAuth, + ): + result = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert result["data_schema"].schema.keys() == { + "username", + "password", + "totp_secret", + } + with patch( "homeassistant.components.opower.config_flow.Opower.async_login", ) as mock_login: @@ -371,3 +569,109 @@ async def test_form_valid_reauth_with_mfa( assert len(mock_unload_entry.mock_calls) == 1 assert len(mock_setup_entry.mock_calls) == 1 assert mock_login.call_count == 1 + + +async def test_reauth_with_mfa_challenge( + recorder_mock: Recorder, + hass: HomeAssistant, + mock_setup_entry: AsyncMock, + mock_unload_entry: AsyncMock, + mock_config_entry: MockConfigEntry, +) -> None: + """Test the full interactive MFA flow during reauth.""" + # 1. Set up the existing entry and trigger reauth + mock_config_entry.mock_state(hass, ConfigEntryState.LOADED) + hass.config.components.add(DOMAIN) + mock_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + + # 2. Test failure before MFA challenge (InvalidAuth) + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=InvalidAuth, + ) as mock_login_fail_auth: + result_invalid_auth = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "bad-password", + }, + ) + mock_login_fail_auth.assert_awaited_once() + assert result_invalid_auth["type"] is FlowResultType.FORM + assert result_invalid_auth["step_id"] == "reauth_confirm" + assert result_invalid_auth["errors"] == {"base": "invalid_auth"} + + # 3. Test failure before MFA challenge (CannotConnect) + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=CannotConnect, + ) as mock_login_fail_connect: + result_cannot_connect = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new-password", + }, + ) + mock_login_fail_connect.assert_awaited_once() + assert result_cannot_connect["type"] is FlowResultType.FORM + assert result_cannot_connect["step_id"] == "reauth_confirm" + assert result_cannot_connect["errors"] == {"base": "cannot_connect"} + + # 4. Trigger the MfaChallenge on the next attempt + mock_mfa_handler = AsyncMock() + mock_mfa_handler.async_get_mfa_options.return_value = { + "Email": "fooxxx@mail.com", + "Phone": "xxx-123", + } + mock_mfa_handler.async_submit_mfa_code.return_value = { + "login_data_mock_key": "login_data_mock_value" + } + with patch( + "homeassistant.components.opower.config_flow.Opower.async_login", + side_effect=MfaChallenge(message="", handler=mock_mfa_handler), + ) as mock_login_mfa: + result_mfa_challenge = await hass.config_entries.flow.async_configure( + result["flow_id"], + { + "username": "test-username", + "password": "new-password", + }, + ) + mock_login_mfa.assert_awaited_once() + + # 5. Handle the happy path for the MFA flow + assert result_mfa_challenge["type"] is FlowResultType.FORM + assert result_mfa_challenge["step_id"] == "mfa_options" + mock_mfa_handler.async_get_mfa_options.assert_awaited_once() + + result_mfa_code = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_method": "Phone"} + ) + mock_mfa_handler.async_select_mfa_option.assert_awaited_once_with("Phone") + assert result_mfa_code["type"] is FlowResultType.FORM + assert result_mfa_code["step_id"] == "mfa_code" + + result_final = await hass.config_entries.flow.async_configure( + result["flow_id"], {"mfa_code": "good-code"} + ) + mock_mfa_handler.async_submit_mfa_code.assert_awaited_once_with("good-code") + + # 6. Verify the reauth completes successfully + assert result_final["type"] is FlowResultType.ABORT + assert result_final["reason"] == "reauth_successful" + await hass.async_block_till_done() + + # Check that data was updated and the entry was reloaded + assert mock_config_entry.data["password"] == "new-password" + assert mock_config_entry.data["login_data"] == { + "login_data_mock_key": "login_data_mock_value" + } + assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 From 1302b6744e56971828ea80b4f692fd6289a62a38 Mon Sep 17 00:00:00 2001 From: Jan Bouwhuis Date: Wed, 6 Aug 2025 11:51:31 +0200 Subject: [PATCH 02/17] Deprecate MQTT vacuum battery feature and remove it as default feature (#149877) Co-authored-by: Martin Hjelmare --- homeassistant/components/mqtt/strings.json | 4 ++ homeassistant/components/mqtt/vacuum.py | 36 +++++++++-- tests/components/mqtt/test_vacuum.py | 74 ++++++++++++++++++++-- 3 files changed, 103 insertions(+), 11 deletions(-) diff --git a/homeassistant/components/mqtt/strings.json b/homeassistant/components/mqtt/strings.json index 1528516504790..77a476bf40cc7 100644 --- a/homeassistant/components/mqtt/strings.json +++ b/homeassistant/components/mqtt/strings.json @@ -1,5 +1,9 @@ { "issues": { + "deprecated_vacuum_battery_feature": { + "title": "Deprecated battery feature used", + "description": "Vacuum entity {entity_id} implements the battery feature which is deprecated. This will stop working in Home Assistant 2026.2. Implement a separate entity for the battery state instead. To fix the issue, remove the `battery` feature from the configured supported features, and restart Home Assistant." + }, "invalid_platform_config": { "title": "Invalid config found for MQTT {domain} item", "description": "Home Assistant detected an invalid config for a manually configured item.\n\nPlatform domain: **{domain}**\nConfiguration file: **{config_file}**\nNear line: **{line}**\nConfiguration found:\n```yaml\n{config}\n```\nError: **{error}**.\n\nMake sure the configuration is valid and [reload](/developer-tools/yaml) the manually configured MQTT items or restart Home Assistant to fix this issue." diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index f1d2eb34fe185..28cc883fa9e6c 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -17,7 +17,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_SUPPORTED_FEATURES, CONF_NAME from homeassistant.core import HomeAssistant, callback -from homeassistant.helpers import config_validation as cv +from homeassistant.helpers import config_validation as cv, issue_registry as ir from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from homeassistant.helpers.json import json_dumps from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType, VolSchemaType @@ -25,11 +25,11 @@ from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC -from .entity import MqttEntity, async_setup_entity_entry_helper +from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, DOMAIN +from .entity import IssueSeverity, MqttEntity, async_setup_entity_entry_helper from .models import ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA -from .util import valid_publish_topic +from .util import learn_more_url, valid_publish_topic PARALLEL_UPDATES = 0 @@ -84,6 +84,8 @@ VacuumEntityFeature.STOP: "stop", VacuumEntityFeature.RETURN_HOME: "return_home", VacuumEntityFeature.FAN_SPEED: "fan_speed", + # Use of the battery feature was deprecated in HA Core 2025.8 + # and will be removed with HA Core 2026.2 VacuumEntityFeature.BATTERY: "battery", VacuumEntityFeature.STATUS: "status", VacuumEntityFeature.SEND_COMMAND: "send_command", @@ -96,7 +98,6 @@ VacuumEntityFeature.START | VacuumEntityFeature.STOP | VacuumEntityFeature.RETURN_HOME - | VacuumEntityFeature.BATTERY | VacuumEntityFeature.CLEAN_SPOT ) ALL_SERVICES = ( @@ -251,10 +252,35 @@ def _strings_to_services( ) } + async def mqtt_async_added_to_hass(self) -> None: + """Check for use of deprecated battery features.""" + if self.supported_features & VacuumEntityFeature.BATTERY: + ir.async_create_issue( + self.hass, + DOMAIN, + f"deprecated_vacuum_battery_feature_{self.entity_id}", + issue_domain=vacuum.DOMAIN, + breaks_in_ha_version="2026.2", + is_fixable=False, + severity=IssueSeverity.WARNING, + learn_more_url=learn_more_url(vacuum.DOMAIN), + translation_placeholders={"entity_id": self.entity_id}, + translation_key="deprecated_vacuum_battery_feature", + ) + _LOGGER.warning( + "MQTT vacuum entity %s implements the battery feature " + "which is deprecated. This will stop working " + "in Home Assistant 2026.2. Implement a separate entity " + "for the battery status instead", + self.entity_id, + ) + def _update_state_attributes(self, payload: dict[str, Any]) -> None: """Update the entity state attributes.""" self._state_attrs.update(payload) self._attr_fan_speed = self._state_attrs.get(FAN_SPEED, 0) + # Use of the battery feature was deprecated in HA Core 2025.8 + # and will be removed with HA Core 2026.2 self._attr_battery_level = max(0, min(100, self._state_attrs.get(BATTERY, 0))) @callback diff --git a/tests/components/mqtt/test_vacuum.py b/tests/components/mqtt/test_vacuum.py index ba404e2dff05c..77b90403823c3 100644 --- a/tests/components/mqtt/test_vacuum.py +++ b/tests/components/mqtt/test_vacuum.py @@ -32,6 +32,7 @@ from homeassistant.const import CONF_NAME, ENTITY_MATCH_ALL, STATE_UNKNOWN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.helpers import issue_registry as ir from .common import ( help_custom_config, @@ -108,7 +109,7 @@ async def test_default_supported_features( entity = hass.states.get("vacuum.mqtttest") entity_features = entity.attributes.get(mqttvacuum.CONF_SUPPORTED_FEATURES, 0) assert sorted(services_to_strings(entity_features, SERVICE_TO_STRING)) == sorted( - ["start", "stop", "return_home", "battery", "clean_spot"] + ["start", "stop", "return_home", "clean_spot"] ) @@ -313,8 +314,6 @@ async def test_status( async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") assert state.state == VacuumActivity.CLEANING - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" assert state.attributes.get(ATTR_FAN_SPEED) == "max" message = """{ @@ -326,8 +325,6 @@ async def test_status( async_fire_mqtt_message(hass, "vacuum/state", message) state = hass.states.get("vacuum.mqtttest") assert state.state == VacuumActivity.DOCKED - assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-charging-60" - assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 assert state.attributes.get(ATTR_FAN_SPEED) == "min" assert state.attributes.get(ATTR_FAN_SPEED_LIST) == ["min", "medium", "high", "max"] @@ -337,6 +334,69 @@ async def test_status( assert state.state == STATE_UNKNOWN +# Use of the battery feature was deprecated in HA Core 2025.8 +# and will be removed with HA Core 2026.2 +@pytest.mark.parametrize( + "hass_config", + [ + help_custom_config( + vacuum.DOMAIN, + DEFAULT_CONFIG, + ({mqttvacuum.CONF_SUPPORTED_FEATURES: ["battery"]},), + ) + ], +) +async def test_status_with_deprecated_battery_feature( + hass: HomeAssistant, + mqtt_mock_entry: MqttMockHAClientGenerator, + caplog: pytest.LogCaptureFixture, +) -> None: + """Test status updates from the vacuum with deprecated battery feature.""" + await mqtt_mock_entry() + state = hass.states.get("vacuum.mqtttest") + assert state.state == STATE_UNKNOWN + + message = """{ + "battery_level": 54, + "state": "cleaning" + }""" + async_fire_mqtt_message(hass, "vacuum/state", message) + state = hass.states.get("vacuum.mqtttest") + assert state.state == VacuumActivity.CLEANING + assert state.attributes.get(ATTR_BATTERY_LEVEL) == 54 + assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-50" + + message = """{ + "battery_level": 61, + "state": "docked" + }""" + + async_fire_mqtt_message(hass, "vacuum/state", message) + state = hass.states.get("vacuum.mqtttest") + assert state.state == VacuumActivity.DOCKED + assert state.attributes.get(ATTR_BATTERY_ICON) == "mdi:battery-charging-60" + assert state.attributes.get(ATTR_BATTERY_LEVEL) == 61 + + message = '{"state":null}' + async_fire_mqtt_message(hass, "vacuum/state", message) + state = hass.states.get("vacuum.mqtttest") + assert state.state == STATE_UNKNOWN + assert ( + "MQTT vacuum entity vacuum.mqtttest implements " + "the battery feature which is deprecated." in caplog.text + ) + + # assert a repair issue was created for the entity + issue_registry = ir.async_get(hass) + issue = issue_registry.async_get_issue( + mqtt.DOMAIN, "deprecated_vacuum_battery_feature_vacuum.mqtttest" + ) + assert issue is not None + assert issue.issue_domain == "vacuum" + assert issue.translation_key == "deprecated_vacuum_battery_feature" + assert issue.translation_placeholders == {"entity_id": "vacuum.mqtttest"} + + @pytest.mark.parametrize( "hass_config", [ @@ -346,7 +406,9 @@ async def test_status( ( { mqttvacuum.CONF_SUPPORTED_FEATURES: services_to_strings( - mqttvacuum.DEFAULT_SERVICES, SERVICE_TO_STRING + mqttvacuum.DEFAULT_SERVICES + | vacuum.VacuumEntityFeature.BATTERY, + SERVICE_TO_STRING, ) }, ), From 932bf81ac8dffd89011a229c3225828929e8d84e Mon Sep 17 00:00:00 2001 From: Michael <35783820+mib1185@users.noreply.github.com> Date: Wed, 6 Aug 2025 12:42:51 +0200 Subject: [PATCH 03/17] Add common constant `ATTR_CONFIG_ENTRY_ID` (#150067) --- homeassistant/components/amberelectric/const.py | 1 - homeassistant/components/amberelectric/services.py | 2 +- homeassistant/components/blink/const.py | 1 - homeassistant/components/blink/services.py | 4 ++-- homeassistant/components/bosch_alarm/const.py | 1 - homeassistant/components/bosch_alarm/services.py | 3 ++- homeassistant/components/ecobee/const.py | 1 - homeassistant/components/huawei_lte/__init__.py | 2 +- homeassistant/components/huawei_lte/const.py | 2 -- homeassistant/components/huawei_lte/notify.py | 4 ++-- homeassistant/components/mastodon/const.py | 1 - homeassistant/components/mastodon/services.py | 2 +- homeassistant/components/mealie/const.py | 1 - homeassistant/components/mealie/services.py | 3 +-- homeassistant/components/mobile_app/const.py | 1 - homeassistant/components/music_assistant/actions.py | 2 +- homeassistant/components/music_assistant/const.py | 1 - homeassistant/components/overseerr/const.py | 1 - homeassistant/components/overseerr/services.py | 10 ++-------- homeassistant/components/picnic/const.py | 1 - homeassistant/components/picnic/services.py | 2 +- homeassistant/components/rainbird/const.py | 1 - homeassistant/components/seventeentrack/const.py | 1 - homeassistant/components/seventeentrack/services.py | 3 +-- homeassistant/components/stookwijzer/const.py | 1 - homeassistant/components/stookwijzer/services.py | 3 ++- .../components/swiss_public_transport/const.py | 1 - .../components/swiss_public_transport/services.py | 2 +- homeassistant/components/webostv/__init__.py | 9 ++------- homeassistant/components/webostv/const.py | 1 - homeassistant/components/webostv/notify.py | 4 ++-- homeassistant/components/zwave_js/const.py | 1 - homeassistant/components/zwave_js/triggers/event.py | 8 ++++++-- .../components/zwave_js/triggers/trigger_helpers.py | 4 ++-- homeassistant/const.py | 3 +++ tests/components/amberelectric/test_services.py | 6 ++---- tests/components/blink/test_services.py | 8 ++------ tests/components/bosch_alarm/test_services.py | 2 +- tests/components/mastodon/test_services.py | 2 +- tests/components/mealie/test_services.py | 3 +-- tests/components/music_assistant/test_actions.py | 2 +- tests/components/overseerr/test_services.py | 2 +- tests/components/stookwijzer/test_services.py | 7 ++----- .../components/swiss_public_transport/test_service.py | 2 +- 44 files changed, 45 insertions(+), 77 deletions(-) diff --git a/homeassistant/components/amberelectric/const.py b/homeassistant/components/amberelectric/const.py index bdb9aa3186c4c..490ef3dc2dcb6 100644 --- a/homeassistant/components/amberelectric/const.py +++ b/homeassistant/components/amberelectric/const.py @@ -9,7 +9,6 @@ CONF_SITE_NAME = "site_name" CONF_SITE_ID = "site_id" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_CHANNEL_TYPE = "channel_type" ATTRIBUTION = "Data provided by Amber Electric" diff --git a/homeassistant/components/amberelectric/services.py b/homeassistant/components/amberelectric/services.py index 074a2f0ac88ee..c22a04f28459b 100644 --- a/homeassistant/components/amberelectric/services.py +++ b/homeassistant/components/amberelectric/services.py @@ -4,6 +4,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -16,7 +17,6 @@ from .const import ( ATTR_CHANNEL_TYPE, - ATTR_CONFIG_ENTRY_ID, CONTROLLED_LOAD_CHANNEL, DOMAIN, FEED_IN_CHANNEL, diff --git a/homeassistant/components/blink/const.py b/homeassistant/components/blink/const.py index 0f24eec217828..3e4ffeeea072d 100644 --- a/homeassistant/components/blink/const.py +++ b/homeassistant/components/blink/const.py @@ -25,7 +25,6 @@ SERVICE_SAVE_VIDEO = "save_video" SERVICE_SAVE_RECENT_CLIPS = "save_recent_clips" SERVICE_SEND_PIN = "send_pin" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" PLATFORMS = [ Platform.ALARM_CONTROL_PANEL, diff --git a/homeassistant/components/blink/services.py b/homeassistant/components/blink/services.py index 1f748bd9f63d6..2cb6a3257243f 100644 --- a/homeassistant/components/blink/services.py +++ b/homeassistant/components/blink/services.py @@ -5,12 +5,12 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PIN +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_PIN from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_SEND_PIN +from .const import DOMAIN, SERVICE_SEND_PIN from .coordinator import BlinkConfigEntry SERVICE_SEND_PIN_SCHEMA = vol.Schema( diff --git a/homeassistant/components/bosch_alarm/const.py b/homeassistant/components/bosch_alarm/const.py index 33ec0ae526a2b..d6f651e812400 100644 --- a/homeassistant/components/bosch_alarm/const.py +++ b/homeassistant/components/bosch_alarm/const.py @@ -6,4 +6,3 @@ CONF_USER_CODE = "user_code" ATTR_DATETIME = "datetime" SERVICE_SET_DATE_TIME = "set_date_time" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" diff --git a/homeassistant/components/bosch_alarm/services.py b/homeassistant/components/bosch_alarm/services.py index acdecbda305df..f3292f97ee8e4 100644 --- a/homeassistant/components/bosch_alarm/services.py +++ b/homeassistant/components/bosch_alarm/services.py @@ -9,12 +9,13 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import config_validation as cv from homeassistant.util import dt as dt_util -from .const import ATTR_CONFIG_ENTRY_ID, ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME +from .const import ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME from .types import BoschAlarmConfigEntry diff --git a/homeassistant/components/ecobee/const.py b/homeassistant/components/ecobee/const.py index 115c91eceeb71..cbb3a230c9008 100644 --- a/homeassistant/components/ecobee/const.py +++ b/homeassistant/components/ecobee/const.py @@ -20,7 +20,6 @@ _LOGGER = logging.getLogger(__package__) DOMAIN = "ecobee" -ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_AVAILABLE_SENSORS = "available_sensors" ATTR_ACTIVE_SENSORS = "active_sensors" diff --git a/homeassistant/components/huawei_lte/__init__.py b/homeassistant/components/huawei_lte/__init__.py index 56b7c5023f5f9..a7bd90baefd28 100644 --- a/homeassistant/components/huawei_lte/__init__.py +++ b/homeassistant/components/huawei_lte/__init__.py @@ -24,6 +24,7 @@ from homeassistant.config_entries import ConfigEntry from homeassistant.const import ( + ATTR_CONFIG_ENTRY_ID, ATTR_HW_VERSION, ATTR_MODEL, ATTR_SW_VERSION, @@ -54,7 +55,6 @@ from .const import ( ADMIN_SERVICES, ALL_KEYS, - ATTR_CONFIG_ENTRY_ID, CONF_MANUFACTURER, CONF_UNAUTHENTICATED_MODE, CONF_UPNP_UDN, diff --git a/homeassistant/components/huawei_lte/const.py b/homeassistant/components/huawei_lte/const.py index b766220076769..bc114f56e9980 100644 --- a/homeassistant/components/huawei_lte/const.py +++ b/homeassistant/components/huawei_lte/const.py @@ -2,8 +2,6 @@ DOMAIN = "huawei_lte" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" - CONF_MANUFACTURER = "manufacturer" CONF_TRACK_WIRED_CLIENTS = "track_wired_clients" CONF_UNAUTHENTICATED_MODE = "unauthenticated_mode" diff --git a/homeassistant/components/huawei_lte/notify.py b/homeassistant/components/huawei_lte/notify.py index 682470bafd03f..7543eb71d88c8 100644 --- a/homeassistant/components/huawei_lte/notify.py +++ b/homeassistant/components/huawei_lte/notify.py @@ -8,12 +8,12 @@ from huawei_lte_api.exceptions import ResponseErrorException from homeassistant.components.notify import ATTR_TARGET, BaseNotificationService -from homeassistant.const import CONF_RECIPIENT +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_RECIPIENT from homeassistant.core import HomeAssistant from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import Router -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN +from .const import DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mastodon/const.py b/homeassistant/components/mastodon/const.py index 2efda329467d6..8a77eebcf7a3d 100644 --- a/homeassistant/components/mastodon/const.py +++ b/homeassistant/components/mastodon/const.py @@ -12,7 +12,6 @@ DEFAULT_URL: Final = "https://mastodon.social" DEFAULT_NAME: Final = "Mastodon" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_STATUS = "status" ATTR_VISIBILITY = "visibility" ATTR_CONTENT_WARNING = "content_warning" diff --git a/homeassistant/components/mastodon/services.py b/homeassistant/components/mastodon/services.py index 68e95e726a14f..0815fee34ecd7 100644 --- a/homeassistant/components/mastodon/services.py +++ b/homeassistant/components/mastodon/services.py @@ -9,11 +9,11 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant, ServiceCall, ServiceResponse from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_CONTENT_WARNING, ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, diff --git a/homeassistant/components/mealie/const.py b/homeassistant/components/mealie/const.py index 481cc4ccb7db2..e729265bcbc6d 100644 --- a/homeassistant/components/mealie/const.py +++ b/homeassistant/components/mealie/const.py @@ -8,7 +8,6 @@ LOGGER = logging.getLogger(__package__) -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_START_DATE = "start_date" ATTR_END_DATE = "end_date" ATTR_RECIPE_ID = "recipe_id" diff --git a/homeassistant/components/mealie/services.py b/homeassistant/components/mealie/services.py index f219cea183530..37b485e18f214 100644 --- a/homeassistant/components/mealie/services.py +++ b/homeassistant/components/mealie/services.py @@ -13,7 +13,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_DATE +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DATE from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -25,7 +25,6 @@ from homeassistant.helpers import config_validation as cv from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_END_DATE, ATTR_ENTRY_TYPE, ATTR_INCLUDE_TAGS, diff --git a/homeassistant/components/mobile_app/const.py b/homeassistant/components/mobile_app/const.py index 25c35b3e87e83..1dab894b2f657 100644 --- a/homeassistant/components/mobile_app/const.py +++ b/homeassistant/components/mobile_app/const.py @@ -25,7 +25,6 @@ ATTR_APP_ID = "app_id" ATTR_APP_NAME = "app_name" ATTR_APP_VERSION = "app_version" -ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_DEVICE_NAME = "device_name" ATTR_MANUFACTURER = "manufacturer" ATTR_MODEL = "model" diff --git a/homeassistant/components/music_assistant/actions.py b/homeassistant/components/music_assistant/actions.py index 031229d154480..a0e82ba331521 100644 --- a/homeassistant/components/music_assistant/actions.py +++ b/homeassistant/components/music_assistant/actions.py @@ -8,6 +8,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -24,7 +25,6 @@ ATTR_ALBUMS, ATTR_ARTISTS, ATTR_AUDIOBOOKS, - ATTR_CONFIG_ENTRY_ID, ATTR_FAVORITE, ATTR_ITEMS, ATTR_LIBRARY_ONLY, diff --git a/homeassistant/components/music_assistant/const.py b/homeassistant/components/music_assistant/const.py index d2ee1f7502897..8c1701b4afd7e 100644 --- a/homeassistant/components/music_assistant/const.py +++ b/homeassistant/components/music_assistant/const.py @@ -26,7 +26,6 @@ ATTR_ORDER_BY = "order_by" ATTR_ALBUM_TYPE = "album_type" ATTR_ALBUM_ARTISTS_ONLY = "album_artists_only" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_URI = "uri" ATTR_IMAGE = "image" ATTR_VERSION = "version" diff --git a/homeassistant/components/overseerr/const.py b/homeassistant/components/overseerr/const.py index 2aa0879ffedce..da1fc05160859 100644 --- a/homeassistant/components/overseerr/const.py +++ b/homeassistant/components/overseerr/const.py @@ -9,7 +9,6 @@ REQUESTS = "requests" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_STATUS = "status" ATTR_SORT_ORDER = "sort_order" ATTR_REQUESTED_BY = "requested_by" diff --git a/homeassistant/components/overseerr/services.py b/homeassistant/components/overseerr/services.py index 4e72f5556033d..3c7335de15b64 100644 --- a/homeassistant/components/overseerr/services.py +++ b/homeassistant/components/overseerr/services.py @@ -7,6 +7,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -17,14 +18,7 @@ from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.util.json import JsonValueType -from .const import ( - ATTR_CONFIG_ENTRY_ID, - ATTR_REQUESTED_BY, - ATTR_SORT_ORDER, - ATTR_STATUS, - DOMAIN, - LOGGER, -) +from .const import ATTR_REQUESTED_BY, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, LOGGER from .coordinator import OverseerrConfigEntry SERVICE_GET_REQUESTS = "get_requests" diff --git a/homeassistant/components/picnic/const.py b/homeassistant/components/picnic/const.py index 4e8eafd8912ff..f873780674665 100644 --- a/homeassistant/components/picnic/const.py +++ b/homeassistant/components/picnic/const.py @@ -9,7 +9,6 @@ SERVICE_ADD_PRODUCT_TO_CART = "add_product" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_PRODUCT_ID = "product_id" ATTR_PRODUCT_NAME = "product_name" ATTR_AMOUNT = "amount" diff --git a/homeassistant/components/picnic/services.py b/homeassistant/components/picnic/services.py index 8ecae8dc301dd..d0465fcc13c43 100644 --- a/homeassistant/components/picnic/services.py +++ b/homeassistant/components/picnic/services.py @@ -7,12 +7,12 @@ from python_picnic_api2 import PicnicAPI import voluptuous as vol +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.helpers import config_validation as cv from .const import ( ATTR_AMOUNT, - ATTR_CONFIG_ENTRY_ID, ATTR_PRODUCT_ID, ATTR_PRODUCT_IDENTIFIERS, ATTR_PRODUCT_NAME, diff --git a/homeassistant/components/rainbird/const.py b/homeassistant/components/rainbird/const.py index 8055074f395c5..794afd2287b55 100644 --- a/homeassistant/components/rainbird/const.py +++ b/homeassistant/components/rainbird/const.py @@ -8,6 +8,5 @@ CONF_IMPORTED_NAMES = "imported_names" ATTR_DURATION = "duration" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" TIMEOUT_SECONDS = 20 diff --git a/homeassistant/components/seventeentrack/const.py b/homeassistant/components/seventeentrack/const.py index 988a01f002227..bbf2fcf2638f4 100644 --- a/homeassistant/components/seventeentrack/const.py +++ b/homeassistant/components/seventeentrack/const.py @@ -48,4 +48,3 @@ ATTR_PACKAGE_STATE = "package_state" ATTR_PACKAGE_TRACKING_NUMBER = "package_tracking_number" ATTR_PACKAGE_FRIENDLY_NAME = "package_friendly_name" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" diff --git a/homeassistant/components/seventeentrack/services.py b/homeassistant/components/seventeentrack/services.py index 531ff2aea43ee..bd39b00071fee 100644 --- a/homeassistant/components/seventeentrack/services.py +++ b/homeassistant/components/seventeentrack/services.py @@ -6,7 +6,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry, ConfigEntryState -from homeassistant.const import ATTR_FRIENDLY_NAME, ATTR_LOCATION +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_FRIENDLY_NAME, ATTR_LOCATION from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -20,7 +20,6 @@ from . import SeventeenTrackCoordinator from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_DESTINATION_COUNTRY, ATTR_INFO_TEXT, ATTR_ORIGIN_COUNTRY, diff --git a/homeassistant/components/stookwijzer/const.py b/homeassistant/components/stookwijzer/const.py index 7b4c28540fc10..65b20949fe188 100644 --- a/homeassistant/components/stookwijzer/const.py +++ b/homeassistant/components/stookwijzer/const.py @@ -6,5 +6,4 @@ DOMAIN: Final = "stookwijzer" LOGGER = logging.getLogger(__package__) -ATTR_CONFIG_ENTRY_ID = "config_entry_id" SERVICE_GET_FORECAST = "get_forecast" diff --git a/homeassistant/components/stookwijzer/services.py b/homeassistant/components/stookwijzer/services.py index e8c12717a21b7..1543d7e87773e 100644 --- a/homeassistant/components/stookwijzer/services.py +++ b/homeassistant/components/stookwijzer/services.py @@ -5,6 +5,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -13,7 +14,7 @@ ) from homeassistant.exceptions import ServiceValidationError -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, SERVICE_GET_FORECAST +from .const import DOMAIN, SERVICE_GET_FORECAST from .coordinator import StookwijzerConfigEntry SERVICE_GET_FORECAST_SCHEMA = vol.Schema( diff --git a/homeassistant/components/swiss_public_transport/const.py b/homeassistant/components/swiss_public_transport/const.py index 10bfc0d03555b..c6637adbbef20 100644 --- a/homeassistant/components/swiss_public_transport/const.py +++ b/homeassistant/components/swiss_public_transport/const.py @@ -29,7 +29,6 @@ "opendata_url": "http://transport.opendata.ch", } -ATTR_CONFIG_ENTRY_ID: Final = "config_entry_id" ATTR_LIMIT: Final = "limit" SERVICE_FETCH_CONNECTIONS = "fetch_connections" diff --git a/homeassistant/components/swiss_public_transport/services.py b/homeassistant/components/swiss_public_transport/services.py index 1ac116b4ca929..9297bd4b4093e 100644 --- a/homeassistant/components/swiss_public_transport/services.py +++ b/homeassistant/components/swiss_public_transport/services.py @@ -3,6 +3,7 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntryState +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import ( HomeAssistant, ServiceCall, @@ -19,7 +20,6 @@ from homeassistant.helpers.update_coordinator import UpdateFailed from .const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_LIMIT, CONNECTIONS_COUNT, CONNECTIONS_MAX, diff --git a/homeassistant/components/webostv/__init__.py b/homeassistant/components/webostv/__init__.py index fb729707154cf..b62d7b828afdb 100644 --- a/homeassistant/components/webostv/__init__.py +++ b/homeassistant/components/webostv/__init__.py @@ -8,6 +8,7 @@ from homeassistant.components import notify as hass_notify from homeassistant.const import ( + ATTR_CONFIG_ENTRY_ID, CONF_CLIENT_SECRET, CONF_HOST, CONF_NAME, @@ -20,13 +21,7 @@ from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.typing import ConfigType -from .const import ( - ATTR_CONFIG_ENTRY_ID, - DATA_HASS_CONFIG, - DOMAIN, - PLATFORMS, - WEBOSTV_EXCEPTIONS, -) +from .const import DATA_HASS_CONFIG, DOMAIN, PLATFORMS, WEBOSTV_EXCEPTIONS from .helpers import WebOsTvConfigEntry, update_client_key CONFIG_SCHEMA = cv.config_entry_only_config_schema(DOMAIN) diff --git a/homeassistant/components/webostv/const.py b/homeassistant/components/webostv/const.py index 118ea7b32dbf7..e8774fa24e3d6 100644 --- a/homeassistant/components/webostv/const.py +++ b/homeassistant/components/webostv/const.py @@ -13,7 +13,6 @@ DEFAULT_NAME = "LG webOS TV" ATTR_BUTTON = "button" -ATTR_CONFIG_ENTRY_ID = "entry_id" ATTR_PAYLOAD = "payload" ATTR_SOUND_OUTPUT = "sound_output" diff --git a/homeassistant/components/webostv/notify.py b/homeassistant/components/webostv/notify.py index 3966cea5e924f..a2e9753c17219 100644 --- a/homeassistant/components/webostv/notify.py +++ b/homeassistant/components/webostv/notify.py @@ -7,13 +7,13 @@ from aiowebostv import WebOsClient from homeassistant.components.notify import ATTR_DATA, BaseNotificationService -from homeassistant.const import ATTR_ICON +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_ICON from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import WebOsTvConfigEntry -from .const import ATTR_CONFIG_ENTRY_ID, DOMAIN, WEBOSTV_EXCEPTIONS +from .const import DOMAIN, WEBOSTV_EXCEPTIONS PARALLEL_UPDATES = 0 diff --git a/homeassistant/components/zwave_js/const.py b/homeassistant/components/zwave_js/const.py index 0ccf51539d6a7..69987385d5a04 100644 --- a/homeassistant/components/zwave_js/const.py +++ b/homeassistant/components/zwave_js/const.py @@ -92,7 +92,6 @@ ATTR_CURRENT_VALUE_RAW = "current_value_raw" ATTR_DESCRIPTION = "description" ATTR_EVENT_SOURCE = "event_source" -ATTR_CONFIG_ENTRY_ID = "config_entry_id" ATTR_PARTIAL_DICT_MATCH = "partial_dict_match" # service constants diff --git a/homeassistant/components/zwave_js/triggers/event.py b/homeassistant/components/zwave_js/triggers/event.py index 77449af3e363f..150a32113e6c0 100644 --- a/homeassistant/components/zwave_js/triggers/event.py +++ b/homeassistant/components/zwave_js/triggers/event.py @@ -11,7 +11,12 @@ from zwave_js_server.model.driver import DRIVER_EVENT_MODEL_MAP, Driver from zwave_js_server.model.node import NODE_EVENT_MODEL_MAP -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID, CONF_PLATFORM +from homeassistant.const import ( + ATTR_CONFIG_ENTRY_ID, + ATTR_DEVICE_ID, + ATTR_ENTITY_ID, + CONF_PLATFORM, +) from homeassistant.core import CALLBACK_TYPE, HassJob, HomeAssistant, callback from homeassistant.helpers import config_validation as cv, device_registry as dr from homeassistant.helpers.dispatcher import async_dispatcher_connect @@ -19,7 +24,6 @@ from homeassistant.helpers.typing import ConfigType from ..const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_EVENT, ATTR_EVENT_DATA, ATTR_EVENT_SOURCE, diff --git a/homeassistant/components/zwave_js/triggers/trigger_helpers.py b/homeassistant/components/zwave_js/triggers/trigger_helpers.py index 917d207109f8a..03792771bd3d1 100644 --- a/homeassistant/components/zwave_js/triggers/trigger_helpers.py +++ b/homeassistant/components/zwave_js/triggers/trigger_helpers.py @@ -1,12 +1,12 @@ """Helpers for Z-Wave JS custom triggers.""" from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import ATTR_DEVICE_ID, ATTR_ENTITY_ID +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DEVICE_ID, ATTR_ENTITY_ID from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import device_registry as dr, entity_registry as er from homeassistant.helpers.typing import ConfigType -from ..const import ATTR_CONFIG_ENTRY_ID, DOMAIN +from ..const import DOMAIN @callback diff --git a/homeassistant/const.py b/homeassistant/const.py index 1983932813e06..b678e02569c9b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -469,6 +469,9 @@ class Platform(StrEnum): # Contains one string or a list of strings, each being an entity id ATTR_ENTITY_ID: Final = "entity_id" +# Contains one string, the config entry ID +ATTR_CONFIG_ENTRY_ID: Final = "config_entry_id" + # Contains one string or a list of strings, each being an area id ATTR_AREA_ID: Final = "area_id" diff --git a/tests/components/amberelectric/test_services.py b/tests/components/amberelectric/test_services.py index 7ef895a5d8848..bfff432b18c34 100644 --- a/tests/components/amberelectric/test_services.py +++ b/tests/components/amberelectric/test_services.py @@ -6,10 +6,8 @@ import voluptuous as vol from homeassistant.components.amberelectric.const import DOMAIN, SERVICE_GET_FORECASTS -from homeassistant.components.amberelectric.services import ( - ATTR_CHANNEL_TYPE, - ATTR_CONFIG_ENTRY_ID, -) +from homeassistant.components.amberelectric.services import ATTR_CHANNEL_TYPE +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError diff --git a/tests/components/blink/test_services.py b/tests/components/blink/test_services.py index 856d9e6e8a032..e099b9c24e490 100644 --- a/tests/components/blink/test_services.py +++ b/tests/components/blink/test_services.py @@ -4,13 +4,9 @@ import pytest -from homeassistant.components.blink.const import ( - ATTR_CONFIG_ENTRY_ID, - DOMAIN, - SERVICE_SEND_PIN, -) +from homeassistant.components.blink.const import DOMAIN, SERVICE_SEND_PIN from homeassistant.config_entries import ConfigEntryState -from homeassistant.const import CONF_PIN +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, CONF_PIN from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError diff --git a/tests/components/bosch_alarm/test_services.py b/tests/components/bosch_alarm/test_services.py index 7b5088f32c36b..059b01c1e3b62 100644 --- a/tests/components/bosch_alarm/test_services.py +++ b/tests/components/bosch_alarm/test_services.py @@ -9,11 +9,11 @@ import voluptuous as vol from homeassistant.components.bosch_alarm.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_DATETIME, DOMAIN, SERVICE_SET_DATE_TIME, ) +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.setup import async_setup_component diff --git a/tests/components/mastodon/test_services.py b/tests/components/mastodon/test_services.py index f51d39f8687ed..b08f886422fd8 100644 --- a/tests/components/mastodon/test_services.py +++ b/tests/components/mastodon/test_services.py @@ -6,7 +6,6 @@ import pytest from homeassistant.components.mastodon.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_CONTENT_WARNING, ATTR_MEDIA, ATTR_MEDIA_DESCRIPTION, @@ -15,6 +14,7 @@ DOMAIN, ) from homeassistant.components.mastodon.services import SERVICE_POST +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError diff --git a/tests/components/mealie/test_services.py b/tests/components/mealie/test_services.py index 2ced94a7399f6..8c5d073e3e975 100644 --- a/tests/components/mealie/test_services.py +++ b/tests/components/mealie/test_services.py @@ -14,7 +14,6 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.mealie.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_END_DATE, ATTR_ENTRY_TYPE, ATTR_INCLUDE_TAGS, @@ -35,7 +34,7 @@ SERVICE_SET_MEALPLAN, SERVICE_SET_RANDOM_MEALPLAN, ) -from homeassistant.const import ATTR_DATE +from homeassistant.const import ATTR_CONFIG_ENTRY_ID, ATTR_DATE from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError diff --git a/tests/components/music_assistant/test_actions.py b/tests/components/music_assistant/test_actions.py index c13ea34226259..27253ae2b2096 100644 --- a/tests/components/music_assistant/test_actions.py +++ b/tests/components/music_assistant/test_actions.py @@ -11,12 +11,12 @@ SERVICE_SEARCH, ) from homeassistant.components.music_assistant.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_FAVORITE, ATTR_MEDIA_TYPE, ATTR_SEARCH_NAME, DOMAIN, ) +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from .common import create_library_albums_from_fixture, setup_integration_from_fixtures diff --git a/tests/components/overseerr/test_services.py b/tests/components/overseerr/test_services.py index 3d7bcc3577fba..f53c6a917cb5c 100644 --- a/tests/components/overseerr/test_services.py +++ b/tests/components/overseerr/test_services.py @@ -7,13 +7,13 @@ from syrupy.assertion import SnapshotAssertion from homeassistant.components.overseerr.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_REQUESTED_BY, ATTR_SORT_ORDER, ATTR_STATUS, DOMAIN, ) from homeassistant.components.overseerr.services import SERVICE_GET_REQUESTS +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError diff --git a/tests/components/stookwijzer/test_services.py b/tests/components/stookwijzer/test_services.py index f60730a290d6b..d7ec036d6e4bf 100644 --- a/tests/components/stookwijzer/test_services.py +++ b/tests/components/stookwijzer/test_services.py @@ -3,11 +3,8 @@ import pytest from syrupy.assertion import SnapshotAssertion -from homeassistant.components.stookwijzer.const import ( - ATTR_CONFIG_ENTRY_ID, - DOMAIN, - SERVICE_GET_FORECAST, -) +from homeassistant.components.stookwijzer.const import DOMAIN, SERVICE_GET_FORECAST +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers import entity_registry as er diff --git a/tests/components/swiss_public_transport/test_service.py b/tests/components/swiss_public_transport/test_service.py index 135fb07fda81c..b65ffc12de16e 100644 --- a/tests/components/swiss_public_transport/test_service.py +++ b/tests/components/swiss_public_transport/test_service.py @@ -12,7 +12,6 @@ from voluptuous import error as vol_er from homeassistant.components.swiss_public_transport.const import ( - ATTR_CONFIG_ENTRY_ID, ATTR_LIMIT, CONF_DESTINATION, CONF_START, @@ -22,6 +21,7 @@ SERVICE_FETCH_CONNECTIONS, ) from homeassistant.components.swiss_public_transport.helper import unique_id_from_config +from homeassistant.const import ATTR_CONFIG_ENTRY_ID from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError From 60988534a9848d44ece10d04d98fe89a7e4464e7 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Aug 2025 13:24:37 +0200 Subject: [PATCH 04/17] Enable disabled OpenAI config entries after entry migration (#150099) --- .../openai_conversation/__init__.py | 113 ++++- .../openai_conversation/config_flow.py | 2 +- .../openai_conversation/test_init.py | 413 +++++++++++++++++- 3 files changed, 501 insertions(+), 27 deletions(-) diff --git a/homeassistant/components/openai_conversation/__init__.py b/homeassistant/components/openai_conversation/__init__.py index 77b71ae372d83..f50563b59ea1b 100644 --- a/homeassistant/components/openai_conversation/__init__.py +++ b/homeassistant/components/openai_conversation/__init__.py @@ -272,11 +272,15 @@ async def async_update_options(hass: HomeAssistant, entry: OpenAIConfigEntry) -> async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" - entries = hass.config_entries.async_entries(DOMAIN) + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) if not any(entry.version == 1 for entry in entries): return - api_keys_entries: dict[str, ConfigEntry] = {} + api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {} entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -290,30 +294,61 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if entry.data[CONF_API_KEY] not in api_keys_entries: use_existing = True - api_keys_entries[entry.data[CONF_API_KEY]] = entry + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY] + ) + api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled) - parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] + parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]] hass.config_entries.async_add_subentry(parent_entry, subentry) - conversation_entity = entity_registry.async_get_entity_id( + conversation_entity_id = entity_registry.async_get_entity_id( "conversation", DOMAIN, entry.entry_id, ) - if conversation_entity is not None: + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + + if conversation_entity_id is not None: + conversation_entity_entry = entity_registry.entities[conversation_entity_id] + entity_disabled_by = conversation_entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) entity_registry.async_update_entity( - conversation_entity, + conversation_entity_id, config_entry_id=parent_entry.entry_id, config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, new_unique_id=subentry.subentry_id, ) - device = device_registry.async_get_device( - identifiers={(DOMAIN, entry.entry_id)} - ) if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER device_registry.async_update_device( device.id, + disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, add_config_subentry_id=subentry.subentry_id, add_config_entry_id=parent_entry.entry_id, @@ -333,12 +368,13 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: if not use_existing: await hass.config_entries.async_remove(entry.entry_id) else: + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry( entry, title=DEFAULT_NAME, options={}, version=2, - minor_version=2, + minor_version=4, ) @@ -365,19 +401,56 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> hass.config_entries.async_update_entry(entry, minor_version=2) if entry.version == 2 and entry.minor_version == 2: - hass.config_entries.async_add_subentry( - entry, - ConfigSubentry( - data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), - subentry_type="ai_task_data", - title=DEFAULT_AI_TASK_NAME, - unique_id=None, - ), - ) + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry(entry, minor_version=3) + if entry.version == 2 and entry.minor_version == 3: + # Fix migration where the disabled_by flag was not set correctly. + # We can currently only correct this for enabled config entries, + # because migration does not run for disabled config entries. This + # is asserted in tests, and if that behavior is changed, we should + # correct also disabled config entries. + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if entry.disabled_by is None: + # If the config entry is not disabled, we need to set the disabled_by + # flag on devices to USER, and on entities to DEVICE, if they are set + # to CONFIG_ENTRY. + for device in devices: + if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY: + continue + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + for entity in entity_entries: + if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY: + continue + entity_registry.async_update_entity( + entity.entity_id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + ) + hass.config_entries.async_update_entry(entry, minor_version=4) + LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) return True + + +def _add_ai_task_subentry(hass: HomeAssistant, entry: OpenAIConfigEntry) -> None: + """Add AI Task subentry to the config entry.""" + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType(RECOMMENDED_AI_TASK_OPTIONS), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) diff --git a/homeassistant/components/openai_conversation/config_flow.py b/homeassistant/components/openai_conversation/config_flow.py index aa1c967ca8f6a..c45c2b997b3f1 100644 --- a/homeassistant/components/openai_conversation/config_flow.py +++ b/homeassistant/components/openai_conversation/config_flow.py @@ -98,7 +98,7 @@ class OpenAIConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for OpenAI Conversation.""" VERSION = 2 - MINOR_VERSION = 3 + MINOR_VERSION = 4 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/openai_conversation/test_init.py b/tests/components/openai_conversation/test_init.py index e728d0019b63f..fb8be3b2e6864 100644 --- a/tests/components/openai_conversation/test_init.py +++ b/tests/components/openai_conversation/test_init.py @@ -1,5 +1,6 @@ """Tests for the OpenAI integration.""" +from typing import Any from unittest.mock import AsyncMock, Mock, mock_open, patch import httpx @@ -19,12 +20,18 @@ from homeassistant.components.openai_conversation import CONF_CHAT_MODEL from homeassistant.components.openai_conversation.const import ( DEFAULT_AI_TASK_NAME, + DEFAULT_CONVERSATION_NAME, DOMAIN, + RECOMMENDED_AI_TASK_OPTIONS, + RECOMMENDED_CONVERSATION_OPTIONS, ) -from homeassistant.config_entries import ConfigSubentryData +from homeassistant.config_entries import ConfigEntryDisabler, ConfigSubentryData +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError, ServiceValidationError from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntryDisabler +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -585,7 +592,7 @@ async def test_migration_from_v1( await hass.async_block_till_done() assert mock_config_entry.version == 2 - assert mock_config_entry.minor_version == 3 + assert mock_config_entry.minor_version == 4 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} @@ -714,7 +721,7 @@ async def test_migration_from_v1_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert len(entry.subentries) == 2 @@ -819,7 +826,7 @@ async def test_migration_from_v1_with_same_keys( entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert ( len(entry.subentries) == 3 @@ -855,6 +862,215 @@ async def test_migration_from_v1_with_same_keys( } +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "merged_config_entry_disabled_by", + "conversation_subentry_data", + "main_config_entry", + ), + [ + ( + [ConfigEntryDisabler.USER, None], + None, + [ + { + "conversation_entity_id": "conversation.chatgpt_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + { + "conversation_entity_id": "conversation.chatgpt", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + ], + 1, + ), + ( + [None, ConfigEntryDisabler.USER], + None, + [ + { + "conversation_entity_id": "conversation.chatgpt", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + { + "conversation_entity_id": "conversation.chatgpt_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ( + [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + ConfigEntryDisabler.USER, + [ + { + "conversation_entity_id": "conversation.chatgpt", + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, + "device": 0, + }, + { + "conversation_entity_id": "conversation.chatgpt_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ], +) +async def test_migration_from_v1_disabled( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: list[ConfigEntryDisabler | None], + merged_config_entry_disabled_by: ConfigEntryDisabler | None, + conversation_subentry_data: list[dict[str, Any]], + main_config_entry: int, +) -> None: + """Test migration where the config entries are disabled.""" + # Create a v1 config entry with conversation options and an entity + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "gpt-4o-mini", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="ChatGPT", + disabled_by=config_entry_disabled_by[0], + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="ChatGPT 2", + disabled_by=config_entry_disabled_by[1], + ) + mock_config_entry_2.add_to_hass(hass) + mock_config_entries = [mock_config_entry, mock_config_entry_2] + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="chatgpt", + disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="chatgpt_2", + ) + + devices = [device_1, device_2] + + # Run migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.disabled_by is merged_config_entry_disabled_by + assert entry.version == 2 + assert entry.minor_version == 4 + assert not entry.options + assert entry.title == "OpenAI Conversation" + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "ChatGPT" in subentry.title + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == RECOMMENDED_AI_TASK_OPTIONS + assert ai_task_subentries[0].title == DEFAULT_AI_TASK_NAME + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + + for idx, subentry in enumerate(conversation_subentries): + subentry_data = conversation_subentry_data[idx] + entity = entity_registry.async_get(subentry_data["conversation_entity_id"]) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert entity.disabled_by is subentry_data["entity_disabled_by"] + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == devices[subentry_data["device"]].id + assert device.config_entries == { + mock_config_entries[main_config_entry].entry_id + } + assert device.config_entries_subentries == { + mock_config_entries[main_config_entry].entry_id: {subentry.subentry_id} + } + assert device.disabled_by is subentry_data["device_disabled_by"] + + async def test_migration_from_v2_1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -953,7 +1169,7 @@ async def test_migration_from_v2_1( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert entry.title == "ChatGPT" assert len(entry.subentries) == 3 # 2 conversation + 1 AI task @@ -1089,7 +1305,7 @@ async def test_migration_from_v2_2( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 3 + assert entry.minor_version == 4 assert not entry.options assert entry.title == "ChatGPT" assert len(entry.subentries) == 2 @@ -1114,3 +1330,188 @@ async def test_migration_from_v2_2( ai_task_subentry = ai_task_subentries[0] assert ai_task_subentry.data == {"recommended": True} assert ai_task_subentry.title == "OpenAI AI Task" + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", + "setup_result", + "minor_version_after_migration", + "config_entry_disabled_by_after_migration", + "device_disabled_by_after_migration", + "entity_disabled_by_after_migration", + ), + [ + # Config entry not disabled, update device and entity disabled by config entry + ( + None, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + True, + 4, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + None, + None, + None, + True, + 4, + None, + None, + None, + ), + # Config entry disabled, migration does not run + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + False, + 3, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + ConfigEntryDisabler.USER, + None, + None, + False, + 3, + ConfigEntryDisabler.USER, + None, + None, + ), + ], +) +async def test_migrate_entry_from_v2_3( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: ConfigEntryDisabler | None, + device_disabled_by: DeviceEntryDisabler | None, + entity_disabled_by: RegistryEntryDisabler | None, + setup_result: bool, + minor_version_after_migration: int, + config_entry_disabled_by_after_migration: ConfigEntryDisabler | None, + device_disabled_by_after_migration: ConfigEntryDisabler | None, + entity_disabled_by_after_migration: RegistryEntryDisabler | None, +) -> None: + """Test migration from version 2.3.""" + # Create a v2.3 config entry with conversation subentries + conversation_subentry_id = "blabla" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + disabled_by=config_entry_disabled_by, + version=2, + minor_version=3, + subentries_data=[ + { + "data": RECOMMENDED_CONVERSATION_OPTIONS, + "subentry_id": conversation_subentry_id, + "subentry_type": "conversation", + "title": DEFAULT_CONVERSATION_NAME, + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + conversation_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=conversation_subentry_id, + disabled_by=device_disabled_by, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="OpenAI", + model="ChatGPT", + entry_type=dr.DeviceEntryType.SERVICE, + ) + conversation_entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + config_subentry_id=conversation_subentry_id, + disabled_by=entity_disabled_by, + device_id=conversation_device.id, + suggested_object_id="chatgpt", + ) + + # Verify initial state + assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 3 + assert len(mock_config_entry.subentries) == 1 + assert mock_config_entry.disabled_by == config_entry_disabled_by + assert conversation_device.disabled_by == device_disabled_by + assert conversation_entity.disabled_by == entity_disabled_by + + # Run setup to trigger migration + with patch( + "homeassistant.components.openai_conversation.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is setup_result + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 2 + assert entry.minor_version == minor_version_after_migration + + # Check the disabled_by flag on config entry, device and entity are as expected + conversation_device = device_registry.async_get(conversation_device.id) + conversation_entity = entity_registry.async_get(conversation_entity.entity_id) + assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration + assert conversation_device.disabled_by == device_disabled_by_after_migration + assert conversation_entity.disabled_by == entity_disabled_by_after_migration From e9444a2e4dd0dffb253c15cf7f3828eafde7fcd3 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Aug 2025 13:24:49 +0200 Subject: [PATCH 05/17] Enable disabled Anthropic config entries after entry migration (#150098) --- .../components/anthropic/__init__.py | 89 +++- .../components/anthropic/config_flow.py | 2 +- tests/components/anthropic/test_init.py | 405 +++++++++++++++++- 3 files changed, 479 insertions(+), 17 deletions(-) diff --git a/homeassistant/components/anthropic/__init__.py b/homeassistant/components/anthropic/__init__.py index e143e4d47c22e..b996b7d38c57c 100644 --- a/homeassistant/components/anthropic/__init__.py +++ b/homeassistant/components/anthropic/__init__.py @@ -81,11 +81,15 @@ async def async_update_options( async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" - entries = hass.config_entries.async_entries(DOMAIN) + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) if not any(entry.version == 1 for entry in entries): return - api_keys_entries: dict[str, ConfigEntry] = {} + api_keys_entries: dict[str, tuple[ConfigEntry, bool]] = {} entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -99,30 +103,61 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: ) if entry.data[CONF_API_KEY] not in api_keys_entries: use_existing = True - api_keys_entries[entry.data[CONF_API_KEY]] = entry + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_API_KEY] == entry.data[CONF_API_KEY] + ) + api_keys_entries[entry.data[CONF_API_KEY]] = (entry, all_disabled) - parent_entry = api_keys_entries[entry.data[CONF_API_KEY]] + parent_entry, all_disabled = api_keys_entries[entry.data[CONF_API_KEY]] hass.config_entries.async_add_subentry(parent_entry, subentry) - conversation_entity = entity_registry.async_get_entity_id( + conversation_entity_id = entity_registry.async_get_entity_id( "conversation", DOMAIN, entry.entry_id, ) - if conversation_entity is not None: + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + + if conversation_entity_id is not None: + conversation_entity_entry = entity_registry.entities[conversation_entity_id] + entity_disabled_by = conversation_entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) entity_registry.async_update_entity( - conversation_entity, + conversation_entity_id, config_entry_id=parent_entry.entry_id, config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, new_unique_id=subentry.subentry_id, ) - device = device_registry.async_get_device( - identifiers={(DOMAIN, entry.entry_id)} - ) if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER device_registry.async_update_device( device.id, + disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, add_config_subentry_id=subentry.subentry_id, add_config_entry_id=parent_entry.entry_id, @@ -147,7 +182,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=DEFAULT_CONVERSATION_NAME, options={}, version=2, - minor_version=2, + minor_version=3, ) @@ -173,6 +208,38 @@ async def async_migrate_entry(hass: HomeAssistant, entry: AnthropicConfigEntry) hass.config_entries.async_update_entry(entry, minor_version=2) + if entry.version == 2 and entry.minor_version == 2: + # Fix migration where the disabled_by flag was not set correctly. + # We can currently only correct this for enabled config entries, + # because migration does not run for disabled config entries. This + # is asserted in tests, and if that behavior is changed, we should + # correct also disabled config entries. + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if entry.disabled_by is None: + # If the config entry is not disabled, we need to set the disabled_by + # flag on devices to USER, and on entities to DEVICE, if they are set + # to CONFIG_ENTRY. + for device in devices: + if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY: + continue + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + for entity in entity_entries: + if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY: + continue + entity_registry.async_update_entity( + entity.entity_id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + ) + hass.config_entries.async_update_entry(entry, minor_version=3) + LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) diff --git a/homeassistant/components/anthropic/config_flow.py b/homeassistant/components/anthropic/config_flow.py index 099eae73d31bb..0c555d19bd944 100644 --- a/homeassistant/components/anthropic/config_flow.py +++ b/homeassistant/components/anthropic/config_flow.py @@ -75,7 +75,7 @@ class AnthropicConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Anthropic.""" VERSION = 2 - MINOR_VERSION = 2 + MINOR_VERSION = 3 async def async_step_user( self, user_input: dict[str, Any] | None = None diff --git a/tests/components/anthropic/test_init.py b/tests/components/anthropic/test_init.py index be4f41ad4cd32..ff54539bb39f9 100644 --- a/tests/components/anthropic/test_init.py +++ b/tests/components/anthropic/test_init.py @@ -1,5 +1,6 @@ """Tests for the Anthropic integration.""" +from typing import Any from unittest.mock import patch from anthropic import ( @@ -12,9 +13,12 @@ import pytest from homeassistant.components.anthropic.const import DOMAIN -from homeassistant.config_entries import ConfigSubentryData +from homeassistant.config_entries import ConfigEntryDisabler, ConfigSubentryData +from homeassistant.const import CONF_API_KEY from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er +from homeassistant.helpers.device_registry import DeviceEntryDisabler +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.setup import async_setup_component from tests.common import MockConfigEntry @@ -114,7 +118,7 @@ async def test_migration_from_v1_to_v2( await hass.async_block_till_done() assert mock_config_entry.version == 2 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 assert mock_config_entry.data == {"api_key": "1234"} assert mock_config_entry.options == {} @@ -149,6 +153,207 @@ async def test_migration_from_v1_to_v2( } +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "merged_config_entry_disabled_by", + "conversation_subentry_data", + "main_config_entry", + ), + [ + ( + [ConfigEntryDisabler.USER, None], + None, + [ + { + "conversation_entity_id": "conversation.claude_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + { + "conversation_entity_id": "conversation.claude", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + ], + 1, + ), + ( + [None, ConfigEntryDisabler.USER], + None, + [ + { + "conversation_entity_id": "conversation.claude", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + { + "conversation_entity_id": "conversation.claude_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ( + [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + ConfigEntryDisabler.USER, + [ + { + "conversation_entity_id": "conversation.claude", + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, + "device": 0, + }, + { + "conversation_entity_id": "conversation.claude_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ], +) +async def test_migration_from_v1_disabled( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: list[ConfigEntryDisabler | None], + merged_config_entry_disabled_by: ConfigEntryDisabler | None, + conversation_subentry_data: list[dict[str, Any]], + main_config_entry: int, +) -> None: + """Test migration where the config entries are disabled.""" + # Create a v1 config entry with conversation options and an entity + options = { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + } + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Claude", + disabled_by=config_entry_disabled_by[0], + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "1234"}, + options=options, + version=1, + title="Claude 2", + disabled_by=config_entry_disabled_by[1], + ) + mock_config_entry_2.add_to_hass(hass) + mock_config_entries = [mock_config_entry, mock_config_entry_2] + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="claude", + disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="claude", + ) + + devices = [device_1, device_2] + + # Run migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.disabled_by is merged_config_entry_disabled_by + assert entry.version == 2 + assert entry.minor_version == 3 + assert not entry.options + assert entry.title == "Claude conversation" + assert len(entry.subentries) == 2 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == options + assert "Claude" in subentry.title + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + + for idx, subentry in enumerate(conversation_subentries): + subentry_data = conversation_subentry_data[idx] + entity = entity_registry.async_get(subentry_data["conversation_entity_id"]) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert entity.disabled_by is subentry_data["entity_disabled_by"] + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == devices[subentry_data["device"]].id + assert device.config_entries == { + mock_config_entries[main_config_entry].entry_id + } + assert device.config_entries_subentries == { + mock_config_entries[main_config_entry].entry_id: {subentry.subentry_id} + } + assert device.disabled_by is subentry_data["device_disabled_by"] + + async def test_migration_from_v1_to_v2_with_multiple_keys( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -226,7 +431,7 @@ async def test_migration_from_v1_to_v2_with_multiple_keys( for idx, entry in enumerate(entries): assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert len(entry.subentries) == 1 subentry = list(entry.subentries.values())[0] @@ -320,7 +525,7 @@ async def test_migration_from_v1_to_v2_with_same_keys( entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert len(entry.subentries) == 2 # Two subentries from the two original entries @@ -443,7 +648,7 @@ async def test_migration_from_v2_1_to_v2_2( assert len(entries) == 1 entry = entries[0] assert entry.version == 2 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert entry.title == "Claude" assert len(entry.subentries) == 2 @@ -500,3 +705,193 @@ async def test_migration_from_v2_1_to_v2_2( assert device.config_entries_subentries == { mock_config_entry.entry_id: {subentry.subentry_id} } + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", + "setup_result", + "minor_version_after_migration", + "config_entry_disabled_by_after_migration", + "device_disabled_by_after_migration", + "entity_disabled_by_after_migration", + ), + [ + # Config entry not disabled, update device and entity disabled by config entry + ( + None, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + None, + None, + None, + True, + 3, + None, + None, + None, + ), + # Config entry disabled, migration does not run + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + ConfigEntryDisabler.USER, + None, + None, + False, + 2, + ConfigEntryDisabler.USER, + None, + None, + ), + ], +) +async def test_migrate_entry_to_v2_3( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: ConfigEntryDisabler | None, + device_disabled_by: DeviceEntryDisabler | None, + entity_disabled_by: RegistryEntryDisabler | None, + setup_result: bool, + minor_version_after_migration: int, + config_entry_disabled_by_after_migration: ConfigEntryDisabler | None, + device_disabled_by_after_migration: ConfigEntryDisabler | None, + entity_disabled_by_after_migration: RegistryEntryDisabler | None, +) -> None: + """Test migration to version 2.3.""" + # Create a v2.2 config entry with conversation subentries + conversation_subentry_id = "blabla" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_API_KEY: "test-api-key"}, + disabled_by=config_entry_disabled_by, + version=2, + minor_version=2, + subentries_data=[ + { + "data": { + "recommended": True, + "llm_hass_api": ["assist"], + "prompt": "You are a helpful assistant", + "chat_model": "claude-3-haiku-20240307", + }, + "subentry_id": conversation_subentry_id, + "subentry_type": "conversation", + "title": "Claude haiku", + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + conversation_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=conversation_subentry_id, + disabled_by=device_disabled_by, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Anthropic", + model="Claude", + entry_type=dr.DeviceEntryType.SERVICE, + ) + conversation_entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + config_subentry_id=conversation_subentry_id, + disabled_by=entity_disabled_by, + device_id=conversation_device.id, + suggested_object_id="claude", + ) + + # Verify initial state + assert mock_config_entry.version == 2 + assert mock_config_entry.minor_version == 2 + assert len(mock_config_entry.subentries) == 1 + assert mock_config_entry.disabled_by == config_entry_disabled_by + assert conversation_device.disabled_by == device_disabled_by + assert conversation_entity.disabled_by == entity_disabled_by + + # Run setup to trigger migration + with patch( + "homeassistant.components.anthropic.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is setup_result + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 2 + assert entry.minor_version == minor_version_after_migration + + # Check the disabled_by flag on config entry, device and entity are as expected + conversation_device = device_registry.async_get(conversation_device.id) + conversation_entity = entity_registry.async_get(conversation_entity.entity_id) + assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration + assert conversation_device.disabled_by == device_disabled_by_after_migration + assert conversation_entity.disabled_by == entity_disabled_by_after_migration From f26e6ad211f47e510c2f5061ce13dcf68dee5221 Mon Sep 17 00:00:00 2001 From: Martin Hjelmare Date: Wed, 6 Aug 2025 14:14:42 +0200 Subject: [PATCH 06/17] Fix update coordinator ContextVar log for custom integrations (#150100) --- homeassistant/helpers/update_coordinator.py | 2 +- tests/helpers/test_update_coordinator.py | 54 +++++++++++++++------ 2 files changed, 39 insertions(+), 17 deletions(-) diff --git a/homeassistant/helpers/update_coordinator.py b/homeassistant/helpers/update_coordinator.py index 6b566797017d8..16f3b9b696421 100644 --- a/homeassistant/helpers/update_coordinator.py +++ b/homeassistant/helpers/update_coordinator.py @@ -92,7 +92,7 @@ def __init__( frame.report_usage( "relies on ContextVar, but should pass the config entry explicitly.", core_behavior=frame.ReportBehavior.ERROR, - custom_integration_behavior=frame.ReportBehavior.LOG, + custom_integration_behavior=frame.ReportBehavior.IGNORE, breaks_in_ha_version="2026.8", ) diff --git a/tests/helpers/test_update_coordinator.py b/tests/helpers/test_update_coordinator.py index b4216a3fc6d4a..57e80927e7ee6 100644 --- a/tests/helpers/test_update_coordinator.py +++ b/tests/helpers/test_update_coordinator.py @@ -942,17 +942,24 @@ async def test_config_entry_custom_integration( # Default without context should be None crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd.config_entry is None - assert ( - "Detected that integration 'my_integration' relies on ContextVar" - not in caplog.text - ) + # Should not log any warnings about ContextVar usage for custom integrations + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 # Explicit None is OK caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=None ) + assert crd.config_entry is None assert ( "Detected that integration 'my_integration' relies on ContextVar" @@ -961,38 +968,53 @@ async def test_config_entry_custom_integration( # Explicit entry is OK caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=entry ) + assert crd.config_entry is entry - assert ( - "Detected that integration 'my_integration' relies on ContextVar" - not in caplog.text - ) + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 # set ContextVar config_entries.current_entry.set(entry) # Default with ContextVar should match the ContextVar caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int](hass, _LOGGER, name="test") + assert crd.config_entry is entry - assert ( - "Detected that integration 'my_integration' relies on ContextVar" - not in caplog.text - ) + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 # Explicit entry different from ContextVar not recommended, but should work another_entry = MockConfigEntry() caplog.clear() + crd = update_coordinator.DataUpdateCoordinator[int]( hass, _LOGGER, name="test", config_entry=another_entry ) + assert crd.config_entry is another_entry - assert ( - "Detected that integration 'my_integration' relies on ContextVar" - not in caplog.text - ) + frame_records = [ + record + for record in caplog.records + if record.name == "homeassistant.helpers.frame" + and record.levelno >= logging.WARNING + ] + assert len(frame_records) == 0 async def test_listener_unsubscribe_releases_coordinator(hass: HomeAssistant) -> None: From 25aae8944d75943241c84687d1060f4781fbd77c Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Aug 2025 14:17:30 +0200 Subject: [PATCH 07/17] Add Tuya snapshots tests for mzj category (sous-vide) (#150102) --- tests/components/tuya/__init__.py | 6 + .../tuya/fixtures/mzj_qavcakohisj5adyh.json | 119 ++++++++++++++ .../tuya/snapshots/test_number.ambr | 116 +++++++++++++ .../tuya/snapshots/test_sensor.ambr | 153 ++++++++++++++++++ .../tuya/snapshots/test_switch.ambr | 48 ++++++ 5 files changed, 442 insertions(+) create mode 100644 tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index 2c2c67aa8dbd0..f4f99a7df6ada 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -274,6 +274,12 @@ Platform.BINARY_SENSOR, Platform.SENSOR, ], + "mzj_qavcakohisj5adyh": [ + # https://github.com/home-assistant/core/issues/141278 + Platform.NUMBER, + Platform.SENSOR, + Platform.SWITCH, + ], "pc_t2afic7i3v1bwhfp": [ # https://github.com/home-assistant/core/issues/149704 Platform.SWITCH, diff --git a/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json b/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json new file mode 100644 index 0000000000000..402e73c732b88 --- /dev/null +++ b/tests/components/tuya/fixtures/mzj_qavcakohisj5adyh.json @@ -0,0 +1,119 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bff434eca843ffc9afmthv", + "name": "Sous Vide", + "category": "mzj", + "product_id": "qavcakohisj5adyh", + "product_name": "Sous Vide", + "online": false, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-01-08T17:56:06+00:00", + "create_time": "2025-01-08T17:56:06+00:00", + "update_time": "2025-01-08T17:56:06+00:00", + "function": { + "start": { + "type": "Boolean", + "value": {} + }, + "cook_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 5999, + "scale": 0, + "step": 1 + } + }, + "cook_temperature": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 250, + "max": 925, + "scale": 1, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status_range": { + "start": { + "type": "Boolean", + "value": {} + }, + "status": { + "type": "Enum", + "value": { + "range": ["standby", "cooking", "done"] + } + }, + "cook_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 5999, + "scale": 0, + "step": 1 + } + }, + "remain_time": { + "type": "Integer", + "value": { + "unit": "min", + "min": 1, + "max": 5999, + "scale": 0, + "step": 1 + } + }, + "cook_temperature": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 250, + "max": 925, + "scale": 1, + "step": 1 + } + }, + "temp_current": { + "type": "Integer", + "value": { + "unit": "℃", + "min": 0, + "max": 1000, + "scale": 1, + "step": 1 + } + }, + "temp_unit_convert": { + "type": "Enum", + "value": { + "range": ["c", "f"] + } + } + }, + "status": { + "start": false, + "status": "standby", + "cook_time": 1, + "remain_time": 1, + "cook_temperature": 550, + "temp_current": 267, + "temp_unit_convert": "c" + }, + "set_up": true, + "support_local": true +} diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index b05b45cdd487f..7ab6af0b8879d 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -469,6 +469,122 @@ 'state': '3.0', }) # --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 92.5, + 'min': 25.0, + 'mode': , + 'step': 0.1, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.sous_vide_cook_temperature', + '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': 'Cook temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_temperature', + 'unique_id': 'tuya.bff434eca843ffc9afmthvcook_temperature', + 'unit_of_measurement': '℃', + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Cook temperature', + 'max': 92.5, + 'min': 25.0, + 'mode': , + 'step': 0.1, + 'unit_of_measurement': '℃', + }), + 'context': , + 'entity_id': 'number.sous_vide_cook_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 5999.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.sous_vide_cook_time', + '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': 'Cook time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'cook_time', + 'unique_id': 'tuya.bff434eca843ffc9afmthvcook_time', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][number.sous_vide_cook_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Cook time', + 'max': 5999.0, + 'min': 1.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'number.sous_vide_cook_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[sd_lr33znaodtyarrrz][number.v20_volume-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index e3ef0b4aa6ab9..1dcb262dfd568 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -1876,6 +1876,159 @@ 'state': '100.0', }) # --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_current_temperature-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sous_vide_current_temperature', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 1, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Current temperature', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'current_temperature', + 'unique_id': 'tuya.bff434eca843ffc9afmthvtemp_current', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_current_temperature-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'temperature', + 'friendly_name': 'Sous Vide Current temperature', + 'state_class': , + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sous_vide_current_temperature', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_remaining_time-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sous_vide_remaining_time', + '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': 'Remaining time', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'remaining_time', + 'unique_id': 'tuya.bff434eca843ffc9afmthvremain_time', + 'unit_of_measurement': , + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_remaining_time-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Remaining time', + 'unit_of_measurement': , + }), + 'context': , + 'entity_id': 'sensor.sous_vide_remaining_time', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_status-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.sous_vide_status', + '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': 'Status', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'sous_vide_status', + 'unique_id': 'tuya.bff434eca843ffc9afmthvstatus', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][sensor.sous_vide_status-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Status', + }), + 'context': , + 'entity_id': 'sensor.sous_vide_status', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[pir_3amxzozho9xp4mkh][sensor.rat_trap_hedge_battery_state-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/tuya/snapshots/test_switch.ambr b/tests/components/tuya/snapshots/test_switch.ambr index 4c73d91c0c91d..fbbf68d26346c 100644 --- a/tests/components/tuya/snapshots/test_switch.ambr +++ b/tests/components/tuya/snapshots/test_switch.ambr @@ -1354,6 +1354,54 @@ 'state': 'on', }) # --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][switch.sous_vide_start-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'switch', + 'entity_category': , + 'entity_id': 'switch.sous_vide_start', + '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': 'Start', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'start', + 'unique_id': 'tuya.bff434eca843ffc9afmthvstart', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[mzj_qavcakohisj5adyh][switch.sous_vide_start-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Sous Vide Start', + }), + 'context': , + 'entity_id': 'switch.sous_vide_start', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'unavailable', + }) +# --- # name: test_platform_setup_and_discovery[pc_t2afic7i3v1bwhfp][switch.bubbelbad_socket_1-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From afe574f74e6f30d79e1425d3856321aa659016d5 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Aug 2025 14:24:01 +0200 Subject: [PATCH 08/17] Simplify DPCode lookup in Tuya (#150052) --- .../components/tuya/alarm_control_panel.py | 3 ++- homeassistant/components/tuya/climate.py | 13 ++++++----- homeassistant/components/tuya/cover.py | 3 ++- homeassistant/components/tuya/entity.py | 23 ++++--------------- homeassistant/components/tuya/fan.py | 9 ++++---- homeassistant/components/tuya/humidifier.py | 6 ++--- homeassistant/components/tuya/light.py | 8 +++---- homeassistant/components/tuya/util.py | 23 +++++++++++++++++++ homeassistant/components/tuya/vacuum.py | 9 ++++---- 9 files changed, 55 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index 61985fb7622fb..d08a3bef7ce56 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -22,6 +22,7 @@ from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData +from .util import get_dpcode @dataclass(frozen=True) @@ -140,7 +141,7 @@ def __init__( self._master_state = enum_type # Determine alarm message - if dp_code := self.find_dpcode(description.alarm_msg, prefer_function=True): + if dp_code := get_dpcode(self.device, description.alarm_msg): self._alarm_msg_dpcode = dp_code @property diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index c8071e68397a8..ecfc96f1d67e9 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -27,6 +27,7 @@ from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData +from .util import get_dpcode TUYA_HVAC_TO_HA = { "auto": HVACMode.HEAT_COOL, @@ -229,7 +230,7 @@ def __init__( self._attr_hvac_modes.append(description.switch_only_hvac_mode) self._attr_preset_modes = unknown_hvac_modes self._attr_supported_features |= ClimateEntityFeature.PRESET_MODE - elif self.find_dpcode(DPCode.SWITCH, prefer_function=True): + elif get_dpcode(self.device, DPCode.SWITCH): self._attr_hvac_modes = [ HVACMode.OFF, description.switch_only_hvac_mode, @@ -261,24 +262,24 @@ def __init__( self._fan_mode_dp_code = enum_type.dpcode # Determine swing modes - if self.find_dpcode( + if get_dpcode( + self.device, ( DPCode.SHAKE, DPCode.SWING, DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL, ), - prefer_function=True, ): self._attr_supported_features |= ClimateEntityFeature.SWING_MODE self._attr_swing_modes = [SWING_OFF] - if self.find_dpcode((DPCode.SHAKE, DPCode.SWING), prefer_function=True): + if get_dpcode(self.device, (DPCode.SHAKE, DPCode.SWING)): self._attr_swing_modes.append(SWING_ON) - if self.find_dpcode(DPCode.SWITCH_HORIZONTAL, prefer_function=True): + if get_dpcode(self.device, DPCode.SWITCH_HORIZONTAL): self._attr_swing_modes.append(SWING_HORIZONTAL) - if self.find_dpcode(DPCode.SWITCH_VERTICAL, prefer_function=True): + if get_dpcode(self.device, DPCode.SWITCH_VERTICAL): self._attr_swing_modes.append(SWING_VERTICAL) if DPCode.SWITCH in self.device.function: diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 7f34aa367ad20..43e3f20deb48a 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -23,6 +23,7 @@ from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData +from .util import get_dpcode @dataclass(frozen=True) @@ -202,7 +203,7 @@ def __init__( self._attr_supported_features = CoverEntityFeature(0) # Check if this cover is based on a switch or has controls - if self.find_dpcode(description.key, prefer_function=True): + if get_dpcode(self.device, description.key): if device.function[description.key].type == "Boolean": self._attr_supported_features |= ( CoverEntityFeature.OPEN | CoverEntityFeature.CLOSE diff --git a/homeassistant/components/tuya/entity.py b/homeassistant/components/tuya/entity.py index fbddfb0ab83b2..0ae0f793afdf7 100644 --- a/homeassistant/components/tuya/entity.py +++ b/homeassistant/components/tuya/entity.py @@ -72,22 +72,17 @@ def find_dpcode( dptype: Literal[DPType.INTEGER], ) -> IntegerTypeData | None: ... - @overload def find_dpcode( self, dpcodes: str | DPCode | tuple[DPCode, ...] | None, *, prefer_function: bool = False, - ) -> DPCode | None: ... + dptype: DPType, + ) -> EnumTypeData | IntegerTypeData | None: + """Find type information for a matching DP code available for this device.""" + if dptype not in (DPType.ENUM, DPType.INTEGER): + raise NotImplementedError("Only ENUM and INTEGER types are supported") - def find_dpcode( - self, - dpcodes: str | DPCode | tuple[DPCode, ...] | None, - *, - prefer_function: bool = False, - dptype: DPType | None = None, - ) -> DPCode | EnumTypeData | IntegerTypeData | None: - """Find a matching DP code available on for this device.""" if dpcodes is None: return None @@ -100,11 +95,6 @@ def find_dpcode( if prefer_function: order = ["function", "status_range"] - # When we are not looking for a specific datatype, we can append status for - # searching - if not dptype: - order.append("status") - for dpcode in dpcodes: for key in order: if dpcode not in getattr(self.device, key): @@ -133,9 +123,6 @@ def find_dpcode( continue return integer_type - if dptype not in (DPType.ENUM, DPType.INTEGER): - return dpcode - return None def get_dptype( diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 4c97b857fb726..fba42ad76cf02 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -24,6 +24,7 @@ from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData, IntegerTypeData +from .util import get_dpcode TUYA_SUPPORT_TYPE = { # Dehumidifier @@ -90,8 +91,8 @@ def __init__( """Init Tuya Fan Device.""" super().__init__(device, device_manager) - self._switch = self.find_dpcode( - (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH), prefer_function=True + self._switch = get_dpcode( + self.device, (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) ) self._attr_preset_modes = [] @@ -120,8 +121,8 @@ def __init__( self._attr_supported_features |= FanEntityFeature.SET_SPEED self._speeds = enum_type - if dpcode := self.find_dpcode( - (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL), prefer_function=True + if dpcode := get_dpcode( + self.device, (DPCode.SWITCH_HORIZONTAL, DPCode.SWITCH_VERTICAL) ): self._oscillate = dpcode self._attr_supported_features |= FanEntityFeature.OSCILLATE diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index 06fdc1545c5b8..5def5c5e16c03 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -21,7 +21,7 @@ from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData -from .util import ActionDPCodeNotFoundError +from .util import ActionDPCodeNotFoundError, get_dpcode @dataclass(frozen=True) @@ -105,8 +105,8 @@ def __init__( self._attr_unique_id = f"{super().unique_id}{description.key}" # Determine main switch DPCode - self._switch_dpcode = self.find_dpcode( - description.dpcode or DPCode(description.key), prefer_function=True + self._switch_dpcode = get_dpcode( + self.device, description.dpcode or DPCode(description.key) ) # Determine humidity parameters diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 7b73e8259004e..9848351047cc2 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -29,7 +29,7 @@ from .const import TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode from .entity import TuyaEntity from .models import IntegerTypeData -from .util import remap_value +from .util import get_dpcode, remap_value @dataclass @@ -515,9 +515,7 @@ def __init__( color_modes: set[ColorMode] = {ColorMode.ONOFF} # Determine DPCodes - self._color_mode_dpcode = self.find_dpcode( - description.color_mode, prefer_function=True - ) + self._color_mode_dpcode = get_dpcode(self.device, description.color_mode) if int_type := self.find_dpcode( description.brightness, dptype=DPType.INTEGER, prefer_function=True @@ -532,7 +530,7 @@ def __init__( ) if ( - dpcode := self.find_dpcode(description.color_data, prefer_function=True) + dpcode := get_dpcode(self.device, description.color_data) ) and self.get_dptype(dpcode) == DPType.JSON: self._color_data_dpcode = dpcode color_modes.add(ColorMode.HS) diff --git a/homeassistant/components/tuya/util.py b/homeassistant/components/tuya/util.py index 916a7cfddf4f8..af6a78c147637 100644 --- a/homeassistant/components/tuya/util.py +++ b/homeassistant/components/tuya/util.py @@ -9,6 +9,29 @@ from .const import DOMAIN, DPCode +def get_dpcode( + device: CustomerDevice, dpcodes: str | DPCode | tuple[DPCode, ...] | None +) -> DPCode | None: + """Get the first matching DPCode from the device or return None.""" + if dpcodes is None: + return None + + if isinstance(dpcodes, DPCode): + dpcodes = (dpcodes,) + elif isinstance(dpcodes, str): + dpcodes = (DPCode(dpcodes),) + + for dpcode in dpcodes: + if ( + dpcode in device.function + or dpcode in device.status + or dpcode in device.status_range + ): + return dpcode + + return None + + def remap_value( value: float, from_min: float = 0, diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index 6b4596ee0533e..c32d773c7921f 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -19,6 +19,7 @@ from .const import TUYA_DISCOVERY_NEW, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData +from .util import get_dpcode TUYA_MODE_RETURN_HOME = "chargego" TUYA_STATUS_TO_HA = { @@ -88,11 +89,11 @@ def __init__(self, device: CustomerDevice, device_manager: Manager) -> None: self._attr_supported_features = ( VacuumEntityFeature.SEND_COMMAND | VacuumEntityFeature.STATE ) - if self.find_dpcode(DPCode.PAUSE, prefer_function=True): + if get_dpcode(self.device, DPCode.PAUSE): self._attr_supported_features |= VacuumEntityFeature.PAUSE self._return_home_use_switch_charge = False - if self.find_dpcode(DPCode.SWITCH_CHARGE, prefer_function=True): + if get_dpcode(self.device, DPCode.SWITCH_CHARGE): self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME self._return_home_use_switch_charge = True elif ( @@ -102,10 +103,10 @@ def __init__(self, device: CustomerDevice, device_manager: Manager) -> None: ) and TUYA_MODE_RETURN_HOME in enum_type.range: self._attr_supported_features |= VacuumEntityFeature.RETURN_HOME - if self.find_dpcode(DPCode.SEEK, prefer_function=True): + if get_dpcode(self.device, DPCode.SEEK): self._attr_supported_features |= VacuumEntityFeature.LOCATE - if self.find_dpcode(DPCode.POWER_GO, prefer_function=True): + if get_dpcode(self.device, DPCode.POWER_GO): self._attr_supported_features |= ( VacuumEntityFeature.STOP | VacuumEntityFeature.START ) From a54f0adf74dd5db836ef420ac08c0e221f360f07 Mon Sep 17 00:00:00 2001 From: Joost Lekkerkerker Date: Wed, 6 Aug 2025 14:27:36 +0200 Subject: [PATCH 09/17] Enable disabled Ollama config entries after entry migration (#150105) --- homeassistant/components/ollama/__init__.py | 141 ++++-- .../components/ollama/config_flow.py | 2 +- tests/components/ollama/test_init.py | 412 +++++++++++++++++- 3 files changed, 513 insertions(+), 42 deletions(-) diff --git a/homeassistant/components/ollama/__init__.py b/homeassistant/components/ollama/__init__.py index e16550c1e94a2..091e58dbe7f71 100644 --- a/homeassistant/components/ollama/__init__.py +++ b/homeassistant/components/ollama/__init__.py @@ -92,11 +92,15 @@ async def async_update_options(hass: HomeAssistant, entry: OllamaConfigEntry) -> async def async_migrate_integration(hass: HomeAssistant) -> None: """Migrate integration entry structure.""" - entries = hass.config_entries.async_entries(DOMAIN) + # Make sure we get enabled config entries first + entries = sorted( + hass.config_entries.async_entries(DOMAIN), + key=lambda e: e.disabled_by is not None, + ) if not any(entry.version == 1 for entry in entries): return - api_keys_entries: dict[str, ConfigEntry] = {} + url_entries: dict[str, tuple[ConfigEntry, bool]] = {} entity_registry = er.async_get(hass) device_registry = dr.async_get(hass) @@ -112,33 +116,64 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: title=entry.title, unique_id=None, ) - if entry.data[CONF_URL] not in api_keys_entries: + if entry.data[CONF_URL] not in url_entries: use_existing = True - api_keys_entries[entry.data[CONF_URL]] = entry + all_disabled = all( + e.disabled_by is not None + for e in entries + if e.data[CONF_URL] == entry.data[CONF_URL] + ) + url_entries[entry.data[CONF_URL]] = (entry, all_disabled) - parent_entry = api_keys_entries[entry.data[CONF_URL]] + parent_entry, all_disabled = url_entries[entry.data[CONF_URL]] hass.config_entries.async_add_subentry(parent_entry, subentry) - conversation_entity = entity_registry.async_get_entity_id( + conversation_entity_id = entity_registry.async_get_entity_id( "conversation", DOMAIN, entry.entry_id, ) - if conversation_entity is not None: + device = device_registry.async_get_device( + identifiers={(DOMAIN, entry.entry_id)} + ) + + if conversation_entity_id is not None: + conversation_entity_entry = entity_registry.entities[conversation_entity_id] + entity_disabled_by = conversation_entity_entry.disabled_by + if ( + entity_disabled_by is er.RegistryEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + # Device and entity registries don't update the disabled_by flag + # when moving a device or entity from one config entry to another, + # so we need to do it manually. + entity_disabled_by = ( + er.RegistryEntryDisabler.DEVICE + if device + else er.RegistryEntryDisabler.USER + ) entity_registry.async_update_entity( - conversation_entity, + conversation_entity_id, config_entry_id=parent_entry.entry_id, config_subentry_id=subentry.subentry_id, + disabled_by=entity_disabled_by, new_unique_id=subentry.subentry_id, ) - device = device_registry.async_get_device( - identifiers={(DOMAIN, entry.entry_id)} - ) if device is not None: + # Device and entity registries don't update the disabled_by flag when + # moving a device or entity from one config entry to another, so we + # need to do it manually. + device_disabled_by = device.disabled_by + if ( + device.disabled_by is dr.DeviceEntryDisabler.CONFIG_ENTRY + and not all_disabled + ): + device_disabled_by = dr.DeviceEntryDisabler.USER device_registry.async_update_device( device.id, + disabled_by=device_disabled_by, new_identifiers={(DOMAIN, subentry.subentry_id)}, add_config_subentry_id=subentry.subentry_id, add_config_entry_id=parent_entry.entry_id, @@ -158,6 +193,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: if not use_existing: await hass.config_entries.async_remove(entry.entry_id) else: + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry( entry, title=DEFAULT_NAME, @@ -165,7 +201,7 @@ async def async_migrate_integration(hass: HomeAssistant) -> None: data={CONF_URL: entry.data[CONF_URL]}, options={}, version=3, - minor_version=1, + minor_version=3, ) @@ -211,32 +247,69 @@ async def async_migrate_entry(hass: HomeAssistant, entry: OllamaConfigEntry) -> ) if entry.version == 3 and entry.minor_version == 1: - # Add AI Task subentry with default options. We can only create a new - # subentry if we can find an existing model in the entry. The model - # was removed in the previous migration step, so we need to - # check the subentries for an existing model. - existing_model = next( - iter( - model - for subentry in entry.subentries.values() - if (model := subentry.data.get(CONF_MODEL)) is not None - ), - None, - ) - if existing_model: - hass.config_entries.async_add_subentry( - entry, - ConfigSubentry( - data=MappingProxyType({CONF_MODEL: existing_model}), - subentry_type="ai_task_data", - title=DEFAULT_AI_TASK_NAME, - unique_id=None, - ), - ) + _add_ai_task_subentry(hass, entry) hass.config_entries.async_update_entry(entry, minor_version=2) + if entry.version == 3 and entry.minor_version == 2: + # Fix migration where the disabled_by flag was not set correctly. + # We can currently only correct this for enabled config entries, + # because migration does not run for disabled config entries. This + # is asserted in tests, and if that behavior is changed, we should + # correct also disabled config entries. + device_registry = dr.async_get(hass) + entity_registry = er.async_get(hass) + devices = dr.async_entries_for_config_entry(device_registry, entry.entry_id) + entity_entries = er.async_entries_for_config_entry( + entity_registry, entry.entry_id + ) + if entry.disabled_by is None: + # If the config entry is not disabled, we need to set the disabled_by + # flag on devices to USER, and on entities to DEVICE, if they are set + # to CONFIG_ENTRY. + for device in devices: + if device.disabled_by is not dr.DeviceEntryDisabler.CONFIG_ENTRY: + continue + device_registry.async_update_device( + device.id, + disabled_by=dr.DeviceEntryDisabler.USER, + ) + for entity in entity_entries: + if entity.disabled_by is not er.RegistryEntryDisabler.CONFIG_ENTRY: + continue + entity_registry.async_update_entity( + entity.entity_id, + disabled_by=er.RegistryEntryDisabler.DEVICE, + ) + hass.config_entries.async_update_entry(entry, minor_version=3) + _LOGGER.debug( "Migration to version %s:%s successful", entry.version, entry.minor_version ) return True + + +def _add_ai_task_subentry(hass: HomeAssistant, entry: OllamaConfigEntry) -> None: + """Add AI Task subentry to the config entry.""" + # Add AI Task subentry with default options. We can only create a new + # subentry if we can find an existing model in the entry. The model + # was removed in the previous migration step, so we need to + # check the subentries for an existing model. + existing_model = next( + iter( + model + for subentry in entry.subentries.values() + if (model := subentry.data.get(CONF_MODEL)) is not None + ), + None, + ) + if existing_model: + hass.config_entries.async_add_subentry( + entry, + ConfigSubentry( + data=MappingProxyType({CONF_MODEL: existing_model}), + subentry_type="ai_task_data", + title=DEFAULT_AI_TASK_NAME, + unique_id=None, + ), + ) diff --git a/homeassistant/components/ollama/config_flow.py b/homeassistant/components/ollama/config_flow.py index cca917f6c2916..68deb00d20565 100644 --- a/homeassistant/components/ollama/config_flow.py +++ b/homeassistant/components/ollama/config_flow.py @@ -76,7 +76,7 @@ class OllamaConfigFlow(ConfigFlow, domain=DOMAIN): """Handle a config flow for Ollama.""" VERSION = 3 - MINOR_VERSION = 2 + MINOR_VERSION = 3 def __init__(self) -> None: """Initialize config flow.""" diff --git a/tests/components/ollama/test_init.py b/tests/components/ollama/test_init.py index 1db5730270410..766de8a7d6d79 100644 --- a/tests/components/ollama/test_init.py +++ b/tests/components/ollama/test_init.py @@ -1,5 +1,6 @@ """Tests for the Ollama integration.""" +from typing import Any from unittest.mock import patch from httpx import ConnectError @@ -7,9 +8,12 @@ from homeassistant.components import ollama from homeassistant.components.ollama.const import DOMAIN -from homeassistant.config_entries import ConfigSubentryData +from homeassistant.config_entries import ConfigEntryDisabler, ConfigSubentryData +from homeassistant.const import CONF_URL from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr, entity_registry as er, llm +from homeassistant.helpers.device_registry import DeviceEntryDisabler +from homeassistant.helpers.entity_registry import RegistryEntryDisabler from homeassistant.setup import async_setup_component from . import TEST_OPTIONS @@ -96,7 +100,7 @@ async def test_migration_from_v1( await hass.async_block_till_done() assert mock_config_entry.version == 3 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 # After migration, parent entry should only have URL assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} assert mock_config_entry.options == {} @@ -223,7 +227,7 @@ async def test_migration_from_v1_with_multiple_urls( for idx, entry in enumerate(entries): assert entry.version == 3 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert len(entry.subentries) == 2 @@ -332,7 +336,7 @@ async def test_migration_from_v1_with_same_urls( entry = entries[0] assert entry.version == 3 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options # Two conversation subentries from the two original entries and 1 aitask subentry assert len(entry.subentries) == 3 @@ -365,6 +369,209 @@ async def test_migration_from_v1_with_same_urls( } +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "merged_config_entry_disabled_by", + "conversation_subentry_data", + "main_config_entry", + ), + [ + ( + [ConfigEntryDisabler.USER, None], + None, + [ + { + "conversation_entity_id": "conversation.ollama_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + { + "conversation_entity_id": "conversation.ollama", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + ], + 1, + ), + ( + [None, ConfigEntryDisabler.USER], + None, + [ + { + "conversation_entity_id": "conversation.ollama", + "device_disabled_by": DeviceEntryDisabler.USER, + "entity_disabled_by": RegistryEntryDisabler.DEVICE, + "device": 0, + }, + { + "conversation_entity_id": "conversation.ollama_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ( + [ConfigEntryDisabler.USER, ConfigEntryDisabler.USER], + ConfigEntryDisabler.USER, + [ + { + "conversation_entity_id": "conversation.ollama", + "device_disabled_by": DeviceEntryDisabler.CONFIG_ENTRY, + "entity_disabled_by": RegistryEntryDisabler.CONFIG_ENTRY, + "device": 0, + }, + { + "conversation_entity_id": "conversation.ollama_2", + "device_disabled_by": None, + "entity_disabled_by": None, + "device": 1, + }, + ], + 0, + ), + ], +) +async def test_migration_from_v1_disabled( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: list[ConfigEntryDisabler | None], + merged_config_entry_disabled_by: ConfigEntryDisabler | None, + conversation_subentry_data: list[dict[str, Any]], + main_config_entry: int, +) -> None: + """Test migration where the config entries are disabled.""" + # Create a v1 config entry with conversation options and an entity + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, + options=V1_TEST_OPTIONS, + version=1, + title="Ollama", + disabled_by=config_entry_disabled_by[0], + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry_2 = MockConfigEntry( + domain=DOMAIN, + data={"url": "http://localhost:11434", "model": "llama3.2:latest"}, + options=V1_TEST_OPTIONS, + version=1, + title="Ollama 2", + disabled_by=config_entry_disabled_by[1], + ) + mock_config_entry_2.add_to_hass(hass) + mock_config_entries = [mock_config_entry, mock_config_entry_2] + + device_1 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + disabled_by=DeviceEntryDisabler.CONFIG_ENTRY, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + device_id=device_1.id, + suggested_object_id="ollama", + disabled_by=RegistryEntryDisabler.CONFIG_ENTRY, + ) + + device_2 = device_registry.async_get_or_create( + config_entry_id=mock_config_entry_2.entry_id, + identifiers={(DOMAIN, mock_config_entry_2.entry_id)}, + name=mock_config_entry_2.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry_2.entry_id, + config_entry=mock_config_entry_2, + device_id=device_2.id, + suggested_object_id="ollama_2", + ) + + devices = [device_1, device_2] + + # Run migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + await hass.config_entries.async_setup(mock_config_entry.entry_id) + await hass.async_block_till_done() + + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + assert entry.disabled_by is merged_config_entry_disabled_by + assert entry.version == 3 + assert entry.minor_version == 3 + assert not entry.options + assert entry.title == "Ollama" + assert len(entry.subentries) == 3 + conversation_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "conversation" + ] + assert len(conversation_subentries) == 2 + for subentry in conversation_subentries: + assert subentry.subentry_type == "conversation" + assert subentry.data == {"model": "llama3.2:latest", **V1_TEST_OPTIONS} + assert "Ollama" in subentry.title + ai_task_subentries = [ + subentry + for subentry in entry.subentries.values() + if subentry.subentry_type == "ai_task_data" + ] + assert len(ai_task_subentries) == 1 + assert ai_task_subentries[0].data == {"model": "llama3.2:latest"} + assert ai_task_subentries[0].title == "Ollama AI Task" + + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry.entry_id)} + ) + assert not device_registry.async_get_device( + identifiers={(DOMAIN, mock_config_entry_2.entry_id)} + ) + + for idx, subentry in enumerate(conversation_subentries): + subentry_data = conversation_subentry_data[idx] + entity = entity_registry.async_get(subentry_data["conversation_entity_id"]) + assert entity.unique_id == subentry.subentry_id + assert entity.config_subentry_id == subentry.subentry_id + assert entity.config_entry_id == entry.entry_id + assert entity.disabled_by is subentry_data["entity_disabled_by"] + + assert ( + device := device_registry.async_get_device( + identifiers={(DOMAIN, subentry.subentry_id)} + ) + ) + assert device.identifiers == {(DOMAIN, subentry.subentry_id)} + assert device.id == devices[subentry_data["device"]].id + assert device.config_entries == { + mock_config_entries[main_config_entry].entry_id + } + assert device.config_entries_subentries == { + mock_config_entries[main_config_entry].entry_id: {subentry.subentry_id} + } + assert device.disabled_by is subentry_data["device_disabled_by"] + + async def test_migration_from_v2_1( hass: HomeAssistant, device_registry: dr.DeviceRegistry, @@ -457,7 +664,7 @@ async def test_migration_from_v2_1( assert len(entries) == 1 entry = entries[0] assert entry.version == 3 - assert entry.minor_version == 2 + assert entry.minor_version == 3 assert not entry.options assert entry.title == "Ollama" assert len(entry.subentries) == 3 @@ -546,7 +753,7 @@ async def test_migration_from_v2_2(hass: HomeAssistant) -> None: # Check migration to v3.1 assert mock_config_entry.version == 3 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 # Check that model was moved from main data to subentry assert mock_config_entry.data == {ollama.CONF_URL: "http://localhost:11434"} @@ -584,6 +791,197 @@ async def test_migration_from_v3_1_without_subentry(hass: HomeAssistant) -> None await hass.config_entries.async_setup(mock_config_entry.entry_id) assert mock_config_entry.version == 3 - assert mock_config_entry.minor_version == 2 + assert mock_config_entry.minor_version == 3 assert next(iter(mock_config_entry.subentries.values()), None) is None + + +@pytest.mark.parametrize( + ( + "config_entry_disabled_by", + "device_disabled_by", + "entity_disabled_by", + "setup_result", + "minor_version_after_migration", + "config_entry_disabled_by_after_migration", + "device_disabled_by_after_migration", + "entity_disabled_by_after_migration", + ), + [ + # Config entry not disabled, update device and entity disabled by config entry + ( + None, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + True, + 3, + None, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + None, + None, + None, + True, + 3, + None, + None, + None, + ), + # Config entry disabled, migration does not run + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.CONFIG_ENTRY, + RegistryEntryDisabler.CONFIG_ENTRY, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.DEVICE, + ), + ( + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + False, + 2, + ConfigEntryDisabler.USER, + DeviceEntryDisabler.USER, + RegistryEntryDisabler.USER, + ), + ( + ConfigEntryDisabler.USER, + None, + None, + False, + 2, + ConfigEntryDisabler.USER, + None, + None, + ), + ], +) +async def test_migrate_entry_from_v3_2( + hass: HomeAssistant, + device_registry: dr.DeviceRegistry, + entity_registry: er.EntityRegistry, + config_entry_disabled_by: ConfigEntryDisabler | None, + device_disabled_by: DeviceEntryDisabler | None, + entity_disabled_by: RegistryEntryDisabler | None, + setup_result: bool, + minor_version_after_migration: int, + config_entry_disabled_by_after_migration: ConfigEntryDisabler | None, + device_disabled_by_after_migration: ConfigEntryDisabler | None, + entity_disabled_by_after_migration: RegistryEntryDisabler | None, +) -> None: + """Test migration from version 3.2.""" + # Create a v3.2 config entry with conversation subentries + conversation_subentry_id = "blabla" + mock_config_entry = MockConfigEntry( + domain=DOMAIN, + data={CONF_URL: "http://localhost:11434"}, + disabled_by=config_entry_disabled_by, + version=3, + minor_version=2, + subentries_data=[ + { + "data": V1_TEST_OPTIONS, + "subentry_id": conversation_subentry_id, + "subentry_type": "conversation", + "title": "Ollama", + "unique_id": None, + }, + { + "data": {"model": "llama3.2:latest"}, + "subentry_type": "ai_task_data", + "title": "Ollama AI Task", + "unique_id": None, + }, + ], + ) + mock_config_entry.add_to_hass(hass) + + conversation_device = device_registry.async_get_or_create( + config_entry_id=mock_config_entry.entry_id, + config_subentry_id=conversation_subentry_id, + disabled_by=device_disabled_by, + identifiers={(DOMAIN, mock_config_entry.entry_id)}, + name=mock_config_entry.title, + manufacturer="Ollama", + model="Ollama", + entry_type=dr.DeviceEntryType.SERVICE, + ) + conversation_entity = entity_registry.async_get_or_create( + "conversation", + DOMAIN, + mock_config_entry.entry_id, + config_entry=mock_config_entry, + config_subentry_id=conversation_subentry_id, + disabled_by=entity_disabled_by, + device_id=conversation_device.id, + suggested_object_id="ollama", + ) + + # Verify initial state + assert mock_config_entry.version == 3 + assert mock_config_entry.minor_version == 2 + assert len(mock_config_entry.subentries) == 2 + assert mock_config_entry.disabled_by == config_entry_disabled_by + assert conversation_device.disabled_by == device_disabled_by + assert conversation_entity.disabled_by == entity_disabled_by + + # Run setup to trigger migration + with patch( + "homeassistant.components.ollama.async_setup_entry", + return_value=True, + ): + result = await hass.config_entries.async_setup(mock_config_entry.entry_id) + assert result is setup_result + await hass.async_block_till_done() + + # Verify migration completed + entries = hass.config_entries.async_entries(DOMAIN) + assert len(entries) == 1 + entry = entries[0] + + # Check version and subversion were updated + assert entry.version == 3 + assert entry.minor_version == minor_version_after_migration + + # Check the disabled_by flag on config entry, device and entity are as expected + conversation_device = device_registry.async_get(conversation_device.id) + conversation_entity = entity_registry.async_get(conversation_entity.entity_id) + assert mock_config_entry.disabled_by == config_entry_disabled_by_after_migration + assert conversation_device.disabled_by == device_disabled_by_after_migration + assert conversation_entity.disabled_by == entity_disabled_by_after_migration From 1efe2b437dd81362525b85a22623c797de745def Mon Sep 17 00:00:00 2001 From: markhannon Date: Wed, 6 Aug 2025 22:50:06 +1000 Subject: [PATCH 10/17] Improve dependency transparency for Zimi integration (#145879) --- homeassistant/components/zimi/quality_scale.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/homeassistant/components/zimi/quality_scale.yaml b/homeassistant/components/zimi/quality_scale.yaml index 98e6c5b627c02..8b8b85c71f41d 100644 --- a/homeassistant/components/zimi/quality_scale.yaml +++ b/homeassistant/components/zimi/quality_scale.yaml @@ -16,6 +16,7 @@ rules: status: done comment: | https://mark_hannon@bitbucket.org/mark_hannon/zcc.git + https://bitbucket.org/mark_hannon/zcc/src/master/bitbucket-pipelines.yml docs-actions: status: exempt comment: | From 33421bddf3771fcba2a342e3a9748f7aa800f9f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20S=C3=B8rensen?= Date: Wed, 6 Aug 2025 13:51:43 +0100 Subject: [PATCH 11/17] Remove myself as codeowner from traccar_server (#150107) --- CODEOWNERS | 2 -- homeassistant/components/traccar_server/manifest.json | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/CODEOWNERS b/CODEOWNERS index 5ef8479d4d388..84a07305d366f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1613,8 +1613,6 @@ build.json @home-assistant/supervisor /tests/components/tplink_omada/ @MarkGodwin /homeassistant/components/traccar/ @ludeeus /tests/components/traccar/ @ludeeus -/homeassistant/components/traccar_server/ @ludeeus -/tests/components/traccar_server/ @ludeeus /homeassistant/components/trace/ @home-assistant/core /tests/components/trace/ @home-assistant/core /homeassistant/components/tractive/ @Danielhiversen @zhulik @bieniu diff --git a/homeassistant/components/traccar_server/manifest.json b/homeassistant/components/traccar_server/manifest.json index 5fac2f108f7d9..18c30e52233cc 100644 --- a/homeassistant/components/traccar_server/manifest.json +++ b/homeassistant/components/traccar_server/manifest.json @@ -1,7 +1,7 @@ { "domain": "traccar_server", "name": "Traccar Server", - "codeowners": ["@ludeeus"], + "codeowners": [], "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/traccar_server", "iot_class": "local_push", From fa3ce62ae8ca7d9b395b3a60995f5087ed506bee Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 6 Aug 2025 14:55:00 +0200 Subject: [PATCH 12/17] Bump holidays to 0.78 (#150103) --- homeassistant/components/holiday/manifest.json | 2 +- homeassistant/components/workday/manifest.json | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index 05cdd2738b623..dde50da1af3d7 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.77", "babel==2.15.0"] + "requirements": ["holidays==0.78", "babel==2.15.0"] } diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 32edd5d3f6a0b..d230970272865 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.77"] + "requirements": ["holidays==0.78"] } diff --git a/requirements_all.txt b/requirements_all.txt index eb2916a4fb980..979f5c519d738 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1171,7 +1171,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.77 +holidays==0.78 # homeassistant.components.frontend home-assistant-frontend==20250805.0 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index 7cc3f6b67ab51..b0d32a231866f 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1020,7 +1020,7 @@ hole==0.9.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.77 +holidays==0.78 # homeassistant.components.frontend home-assistant-frontend==20250805.0 From 2215777cfb95f00d307f989b68d19613930ab079 Mon Sep 17 00:00:00 2001 From: David Poll Date: Wed, 6 Aug 2025 06:20:03 -0700 Subject: [PATCH 13/17] Fix zero-argument functions with as_function (#150062) --- homeassistant/helpers/template.py | 4 ++-- tests/helpers/test_template.py | 17 +++++++++++++++++ 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/homeassistant/helpers/template.py b/homeassistant/helpers/template.py index 85ee1e283093b..8e3106093aaf0 100644 --- a/homeassistant/helpers/template.py +++ b/homeassistant/helpers/template.py @@ -2030,7 +2030,7 @@ def apply(value, fn, *args, **kwargs): def as_function(macro: jinja2.runtime.Macro) -> Callable[..., Any]: """Turn a macro with a 'returns' keyword argument into a function that returns what that argument is called with.""" - def wrapper(value, *args, **kwargs): + def wrapper(*args, **kwargs): return_value = None def returns(value): @@ -2039,7 +2039,7 @@ def returns(value): return value # Call the callable with the value and other args - macro(value, *args, **kwargs, returns=returns) + macro(*args, **kwargs, returns=returns) return return_value # Remove "macro_" from the macro's name to avoid confusion in the wrapper's name diff --git a/tests/helpers/test_template.py b/tests/helpers/test_template.py index 82b6434cf3fe3..85a2673f17d90 100644 --- a/tests/helpers/test_template.py +++ b/tests/helpers/test_template.py @@ -845,6 +845,23 @@ def test_as_function(hass: HomeAssistant) -> None: ) +def test_as_function_no_arguments(hass: HomeAssistant) -> None: + """Test as_function with no arguments.""" + assert ( + template.Template( + """ + {%- macro macro_get_hello(returns) -%} + {%- do returns("Hello") -%} + {%- endmacro -%} + {%- set get_hello = macro_get_hello | as_function -%} + {{ get_hello() }} + """, + hass, + ).async_render() + == "Hello" + ) + + def test_logarithm(hass: HomeAssistant) -> None: """Test logarithm.""" tests = [ From e1f6820cb6e9ade53f85649775b1eef8b9ddc399 Mon Sep 17 00:00:00 2001 From: Bram Kragten Date: Wed, 6 Aug 2025 15:22:46 +0200 Subject: [PATCH 14/17] Update frontend to 20250806.0 (#150106) Co-authored-by: Joost Lekkerkerker --- homeassistant/components/frontend/manifest.json | 2 +- homeassistant/package_constraints.txt | 2 +- requirements_all.txt | 2 +- requirements_test_all.txt | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/homeassistant/components/frontend/manifest.json b/homeassistant/components/frontend/manifest.json index 7be7dd1def9eb..61ca88ba70ac0 100644 --- a/homeassistant/components/frontend/manifest.json +++ b/homeassistant/components/frontend/manifest.json @@ -20,5 +20,5 @@ "documentation": "https://www.home-assistant.io/integrations/frontend", "integration_type": "system", "quality_scale": "internal", - "requirements": ["home-assistant-frontend==20250805.0"] + "requirements": ["home-assistant-frontend==20250806.0"] } diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index b4731f66535ea..28e7491c48c5f 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -38,7 +38,7 @@ habluetooth==4.0.2 hass-nabucasa==0.111.1 hassil==2.2.3 home-assistant-bluetooth==1.13.1 -home-assistant-frontend==20250805.0 +home-assistant-frontend==20250806.0 home-assistant-intents==2025.7.30 httpx==0.28.1 ifaddr==0.2.0 diff --git a/requirements_all.txt b/requirements_all.txt index 979f5c519d738..6b5d56ab6ba38 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1174,7 +1174,7 @@ hole==0.9.0 holidays==0.78 # homeassistant.components.frontend -home-assistant-frontend==20250805.0 +home-assistant-frontend==20250806.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b0d32a231866f..2e0470aa7ad6a 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -1023,7 +1023,7 @@ hole==0.9.0 holidays==0.78 # homeassistant.components.frontend -home-assistant-frontend==20250805.0 +home-assistant-frontend==20250806.0 # homeassistant.components.conversation home-assistant-intents==2025.7.30 From 260ea9a3beb6d77315aa3c7d2a29ef8abe3d1bdd Mon Sep 17 00:00:00 2001 From: G Johansson Date: Wed, 6 Aug 2025 15:24:22 +0200 Subject: [PATCH 15/17] Remove previously deprecated raw value attribute from onewire (#150112) --- homeassistant/components/onewire/entity.py | 2 - .../onewire/snapshots/test_binary_sensor.ambr | 16 ----- .../onewire/snapshots/test_select.ambr | 1 - .../onewire/snapshots/test_sensor.ambr | 58 ------------------- .../onewire/snapshots/test_switch.ambr | 37 ------------ 5 files changed, 114 deletions(-) diff --git a/homeassistant/components/onewire/entity.py b/homeassistant/components/onewire/entity.py index 64c7a8c3ebbab..c66ec3bef15e5 100644 --- a/homeassistant/components/onewire/entity.py +++ b/homeassistant/components/onewire/entity.py @@ -53,8 +53,6 @@ def extra_state_attributes(self) -> dict[str, Any] | None: """Return the state attributes of the entity.""" return { "device_file": self._device_file, - # raw_value attribute is deprecated and can be removed in 2025.8 - "raw_value": self._value_raw, } def _read_value(self) -> str: diff --git a/tests/components/onewire/snapshots/test_binary_sensor.ambr b/tests/components/onewire/snapshots/test_binary_sensor.ambr index 6309b80b28de4..bce1251904a53 100644 --- a/tests/components/onewire/snapshots/test_binary_sensor.ambr +++ b/tests/components/onewire/snapshots/test_binary_sensor.ambr @@ -39,7 +39,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/sensed.A', 'friendly_name': '12.111111111111 Sensed A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.12_111111111111_sensed_a', @@ -89,7 +88,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/sensed.B', 'friendly_name': '12.111111111111 Sensed B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.12_111111111111_sensed_b', @@ -139,7 +137,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.0', 'friendly_name': '29.111111111111 Sensed 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_0', @@ -189,7 +186,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.1', 'friendly_name': '29.111111111111 Sensed 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_1', @@ -239,7 +235,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.2', 'friendly_name': '29.111111111111 Sensed 2', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_2', @@ -289,7 +284,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.3', 'friendly_name': '29.111111111111 Sensed 3', - 'raw_value': None, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_3', @@ -339,7 +333,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.4', 'friendly_name': '29.111111111111 Sensed 4', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_4', @@ -389,7 +382,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.5', 'friendly_name': '29.111111111111 Sensed 5', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_5', @@ -439,7 +431,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.6', 'friendly_name': '29.111111111111 Sensed 6', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_6', @@ -489,7 +480,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/sensed.7', 'friendly_name': '29.111111111111 Sensed 7', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.29_111111111111_sensed_7', @@ -539,7 +529,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/sensed.A', 'friendly_name': '3A.111111111111 Sensed A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.3a_111111111111_sensed_a', @@ -589,7 +578,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/sensed.B', 'friendly_name': '3A.111111111111 Sensed B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.3a_111111111111_sensed_b', @@ -640,7 +628,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.0', 'friendly_name': 'EF.111111111113 Hub short on branch 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_0', @@ -691,7 +678,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.1', 'friendly_name': 'EF.111111111113 Hub short on branch 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_1', @@ -742,7 +728,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.2', 'friendly_name': 'EF.111111111113 Hub short on branch 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_2', @@ -793,7 +778,6 @@ 'device_class': 'problem', 'device_file': '/EF.111111111113/hub/short.3', 'friendly_name': 'EF.111111111113 Hub short on branch 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'binary_sensor.ef_111111111113_hub_short_on_branch_3', diff --git a/tests/components/onewire/snapshots/test_select.ambr b/tests/components/onewire/snapshots/test_select.ambr index 9861a7d2f5e50..d699f717fea8b 100644 --- a/tests/components/onewire/snapshots/test_select.ambr +++ b/tests/components/onewire/snapshots/test_select.ambr @@ -52,7 +52,6 @@ '11', '12', ]), - 'raw_value': 12.0, }), 'context': , 'entity_id': 'select.28_111111111111_temperature_resolution', diff --git a/tests/components/onewire/snapshots/test_sensor.ambr b/tests/components/onewire/snapshots/test_sensor.ambr index 8b49b7f3d5f2a..f19a168456d61 100644 --- a/tests/components/onewire/snapshots/test_sensor.ambr +++ b/tests/components/onewire/snapshots/test_sensor.ambr @@ -45,7 +45,6 @@ 'device_class': 'temperature', 'device_file': '/10.111111111111/temperature', 'friendly_name': '10.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -103,7 +102,6 @@ 'device_class': 'pressure', 'device_file': '/12.111111111111/TAI8570/pressure', 'friendly_name': '12.111111111111 Pressure', - 'raw_value': 1025.123, 'state_class': , 'unit_of_measurement': , }), @@ -161,7 +159,6 @@ 'device_class': 'temperature', 'device_file': '/12.111111111111/TAI8570/temperature', 'friendly_name': '12.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -215,7 +212,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/1D.111111111111/counter.A', 'friendly_name': '1D.111111111111 Counter A', - 'raw_value': 251123.0, 'state_class': , }), 'context': , @@ -268,7 +264,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/1D.111111111111/counter.B', 'friendly_name': '1D.111111111111 Counter B', - 'raw_value': 248125.0, 'state_class': , }), 'context': , @@ -325,7 +320,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.A', 'friendly_name': '20.111111111111 Latest voltage A', - 'raw_value': 1.11, 'state_class': , 'unit_of_measurement': , }), @@ -383,7 +377,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.B', 'friendly_name': '20.111111111111 Latest voltage B', - 'raw_value': 2.22, 'state_class': , 'unit_of_measurement': , }), @@ -441,7 +434,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.C', 'friendly_name': '20.111111111111 Latest voltage C', - 'raw_value': 3.33, 'state_class': , 'unit_of_measurement': , }), @@ -499,7 +491,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/latestvolt.D', 'friendly_name': '20.111111111111 Latest voltage D', - 'raw_value': 4.44, 'state_class': , 'unit_of_measurement': , }), @@ -557,7 +548,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.A', 'friendly_name': '20.111111111111 Voltage A', - 'raw_value': 1.1, 'state_class': , 'unit_of_measurement': , }), @@ -615,7 +605,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.B', 'friendly_name': '20.111111111111 Voltage B', - 'raw_value': 2.2, 'state_class': , 'unit_of_measurement': , }), @@ -673,7 +662,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.C', 'friendly_name': '20.111111111111 Voltage C', - 'raw_value': 3.3, 'state_class': , 'unit_of_measurement': , }), @@ -731,7 +719,6 @@ 'device_class': 'voltage', 'device_file': '/20.111111111111/volt.D', 'friendly_name': '20.111111111111 Voltage D', - 'raw_value': 4.4, 'state_class': , 'unit_of_measurement': , }), @@ -789,7 +776,6 @@ 'device_class': 'temperature', 'device_file': '/22.111111111111/temperature', 'friendly_name': '22.111111111111 Temperature', - 'raw_value': None, 'state_class': , 'unit_of_measurement': , }), @@ -844,7 +830,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HIH3600/humidity', 'friendly_name': '26.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -899,7 +884,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HIH4000/humidity', 'friendly_name': '26.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -954,7 +938,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HIH5030/humidity', 'friendly_name': '26.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -1009,7 +992,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/HTM1735/humidity', 'friendly_name': '26.111111111111 HTM1735 humidity', - 'raw_value': None, 'state_class': , 'unit_of_measurement': '%', }), @@ -1064,7 +1046,6 @@ 'device_class': 'humidity', 'device_file': '/26.111111111111/humidity', 'friendly_name': '26.111111111111 Humidity', - 'raw_value': 72.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -1119,7 +1100,6 @@ 'device_class': 'illuminance', 'device_file': '/26.111111111111/S3-R1-A/illuminance', 'friendly_name': '26.111111111111 Illuminance', - 'raw_value': 65.8839, 'state_class': , 'unit_of_measurement': 'lx', }), @@ -1177,7 +1157,6 @@ 'device_class': 'pressure', 'device_file': '/26.111111111111/B1-R1-A/pressure', 'friendly_name': '26.111111111111 Pressure', - 'raw_value': 969.265, 'state_class': , 'unit_of_measurement': , }), @@ -1235,7 +1214,6 @@ 'device_class': 'temperature', 'device_file': '/26.111111111111/temperature', 'friendly_name': '26.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -1293,7 +1271,6 @@ 'device_class': 'voltage', 'device_file': '/26.111111111111/VAD', 'friendly_name': '26.111111111111 VAD voltage', - 'raw_value': 2.97, 'state_class': , 'unit_of_measurement': , }), @@ -1351,7 +1328,6 @@ 'device_class': 'voltage', 'device_file': '/26.111111111111/VDD', 'friendly_name': '26.111111111111 VDD voltage', - 'raw_value': 4.74, 'state_class': , 'unit_of_measurement': , }), @@ -1409,7 +1385,6 @@ 'device_class': 'voltage', 'device_file': '/26.111111111111/vis', 'friendly_name': '26.111111111111 VIS voltage difference', - 'raw_value': 0.12, 'state_class': , 'unit_of_measurement': , }), @@ -1467,7 +1442,6 @@ 'device_class': 'temperature', 'device_file': '/28.111111111111/temperature', 'friendly_name': '28.111111111111 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1525,7 +1499,6 @@ 'device_class': 'temperature', 'device_file': '/28.222222222222/temperature9', 'friendly_name': '28.222222222222 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1583,7 +1556,6 @@ 'device_class': 'temperature', 'device_file': '/28.222222222223/temperature', 'friendly_name': '28.222222222223 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1641,7 +1613,6 @@ 'device_class': 'temperature', 'device_file': '/30.111111111111/temperature', 'friendly_name': '30.111111111111 Temperature', - 'raw_value': 26.984, 'state_class': , 'unit_of_measurement': , }), @@ -1699,7 +1670,6 @@ 'device_class': 'temperature', 'device_file': '/30.111111111111/typeK/temperature', 'friendly_name': '30.111111111111 Thermocouple K temperature', - 'raw_value': 173.7563, 'state_class': , 'unit_of_measurement': , }), @@ -1757,7 +1727,6 @@ 'device_class': 'voltage', 'device_file': '/30.111111111111/vis', 'friendly_name': '30.111111111111 VIS voltage gradient', - 'raw_value': 0.12, 'state_class': , 'unit_of_measurement': , }), @@ -1815,7 +1784,6 @@ 'device_class': 'voltage', 'device_file': '/30.111111111111/volt', 'friendly_name': '30.111111111111 Voltage', - 'raw_value': 2.97, 'state_class': , 'unit_of_measurement': , }), @@ -1873,7 +1841,6 @@ 'device_class': 'temperature', 'device_file': '/3B.111111111111/temperature', 'friendly_name': '3B.111111111111 Temperature', - 'raw_value': 28.243, 'state_class': , 'unit_of_measurement': , }), @@ -1931,7 +1898,6 @@ 'device_class': 'temperature', 'device_file': '/42.111111111111/temperature', 'friendly_name': '42.111111111111 Temperature', - 'raw_value': 29.123, 'state_class': , 'unit_of_measurement': , }), @@ -1986,7 +1952,6 @@ 'device_class': 'humidity', 'device_file': '/7E.111111111111/EDS0068/humidity', 'friendly_name': '7E.111111111111 Humidity', - 'raw_value': 41.375, 'state_class': , 'unit_of_measurement': '%', }), @@ -2041,7 +2006,6 @@ 'device_class': 'illuminance', 'device_file': '/7E.111111111111/EDS0068/light', 'friendly_name': '7E.111111111111 Illuminance', - 'raw_value': 65.8839, 'state_class': , 'unit_of_measurement': 'lx', }), @@ -2099,7 +2063,6 @@ 'device_class': 'pressure', 'device_file': '/7E.111111111111/EDS0068/pressure', 'friendly_name': '7E.111111111111 Pressure', - 'raw_value': 1012.21, 'state_class': , 'unit_of_measurement': , }), @@ -2157,7 +2120,6 @@ 'device_class': 'temperature', 'device_file': '/7E.111111111111/EDS0068/temperature', 'friendly_name': '7E.111111111111 Temperature', - 'raw_value': 13.9375, 'state_class': , 'unit_of_measurement': , }), @@ -2215,7 +2177,6 @@ 'device_class': 'pressure', 'device_file': '/7E.222222222222/EDS0066/pressure', 'friendly_name': '7E.222222222222 Pressure', - 'raw_value': 1012.21, 'state_class': , 'unit_of_measurement': , }), @@ -2273,7 +2234,6 @@ 'device_class': 'temperature', 'device_file': '/7E.222222222222/EDS0066/temperature', 'friendly_name': '7E.222222222222 Temperature', - 'raw_value': 13.9375, 'state_class': , 'unit_of_measurement': , }), @@ -2328,7 +2288,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HIH3600/humidity', 'friendly_name': 'A6.111111111111 HIH3600 humidity', - 'raw_value': 73.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2383,7 +2342,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HIH4000/humidity', 'friendly_name': 'A6.111111111111 HIH4000 humidity', - 'raw_value': 74.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2438,7 +2396,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HIH5030/humidity', 'friendly_name': 'A6.111111111111 HIH5030 humidity', - 'raw_value': 75.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2493,7 +2450,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/HTM1735/humidity', 'friendly_name': 'A6.111111111111 HTM1735 humidity', - 'raw_value': None, 'state_class': , 'unit_of_measurement': '%', }), @@ -2548,7 +2504,6 @@ 'device_class': 'humidity', 'device_file': '/A6.111111111111/humidity', 'friendly_name': 'A6.111111111111 Humidity', - 'raw_value': 72.7563, 'state_class': , 'unit_of_measurement': '%', }), @@ -2603,7 +2558,6 @@ 'device_class': 'illuminance', 'device_file': '/A6.111111111111/S3-R1-A/illuminance', 'friendly_name': 'A6.111111111111 Illuminance', - 'raw_value': 65.8839, 'state_class': , 'unit_of_measurement': 'lx', }), @@ -2661,7 +2615,6 @@ 'device_class': 'pressure', 'device_file': '/A6.111111111111/B1-R1-A/pressure', 'friendly_name': 'A6.111111111111 Pressure', - 'raw_value': 969.265, 'state_class': , 'unit_of_measurement': , }), @@ -2719,7 +2672,6 @@ 'device_class': 'temperature', 'device_file': '/A6.111111111111/temperature', 'friendly_name': 'A6.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -2777,7 +2729,6 @@ 'device_class': 'voltage', 'device_file': '/A6.111111111111/VAD', 'friendly_name': 'A6.111111111111 VAD voltage', - 'raw_value': 2.97, 'state_class': , 'unit_of_measurement': , }), @@ -2835,7 +2786,6 @@ 'device_class': 'voltage', 'device_file': '/A6.111111111111/VDD', 'friendly_name': 'A6.111111111111 VDD voltage', - 'raw_value': 4.74, 'state_class': , 'unit_of_measurement': , }), @@ -2893,7 +2843,6 @@ 'device_class': 'voltage', 'device_file': '/A6.111111111111/vis', 'friendly_name': 'A6.111111111111 VIS voltage difference', - 'raw_value': 0.12, 'state_class': , 'unit_of_measurement': , }), @@ -2948,7 +2897,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111111/humidity/humidity_corrected', 'friendly_name': 'EF.111111111111 Humidity', - 'raw_value': 67.745, 'state_class': , 'unit_of_measurement': '%', }), @@ -3003,7 +2951,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111111/humidity/humidity_raw', 'friendly_name': 'EF.111111111111 Raw humidity', - 'raw_value': 65.541, 'state_class': , 'unit_of_measurement': '%', }), @@ -3061,7 +3008,6 @@ 'device_class': 'temperature', 'device_file': '/EF.111111111111/humidity/temperature', 'friendly_name': 'EF.111111111111 Temperature', - 'raw_value': 25.123, 'state_class': , 'unit_of_measurement': , }), @@ -3119,7 +3065,6 @@ 'device_class': 'pressure', 'device_file': '/EF.111111111112/moisture/sensor.2', 'friendly_name': 'EF.111111111112 Moisture 2', - 'raw_value': 43.123, 'state_class': , 'unit_of_measurement': , }), @@ -3177,7 +3122,6 @@ 'device_class': 'pressure', 'device_file': '/EF.111111111112/moisture/sensor.3', 'friendly_name': 'EF.111111111112 Moisture 3', - 'raw_value': 44.123, 'state_class': , 'unit_of_measurement': , }), @@ -3232,7 +3176,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111112/moisture/sensor.0', 'friendly_name': 'EF.111111111112 Wetness 0', - 'raw_value': 41.745, 'state_class': , 'unit_of_measurement': '%', }), @@ -3287,7 +3230,6 @@ 'device_class': 'humidity', 'device_file': '/EF.111111111112/moisture/sensor.1', 'friendly_name': 'EF.111111111112 Wetness 1', - 'raw_value': 42.541, 'state_class': , 'unit_of_measurement': '%', }), diff --git a/tests/components/onewire/snapshots/test_switch.ambr b/tests/components/onewire/snapshots/test_switch.ambr index d819fdd0d545b..025fbe1b64b6a 100644 --- a/tests/components/onewire/snapshots/test_switch.ambr +++ b/tests/components/onewire/snapshots/test_switch.ambr @@ -39,7 +39,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/05.111111111111/PIO', 'friendly_name': '05.111111111111 Programmed input-output', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.05_111111111111_programmed_input_output', @@ -89,7 +88,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/latch.A', 'friendly_name': '12.111111111111 Latch A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.12_111111111111_latch_a', @@ -139,7 +137,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/latch.B', 'friendly_name': '12.111111111111 Latch B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.12_111111111111_latch_b', @@ -189,7 +186,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/PIO.A', 'friendly_name': '12.111111111111 Programmed input-output A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.12_111111111111_programmed_input_output_a', @@ -239,7 +235,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/12.111111111111/PIO.B', 'friendly_name': '12.111111111111 Programmed input-output B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.12_111111111111_programmed_input_output_b', @@ -289,7 +284,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/26.111111111111/IAD', 'friendly_name': '26.111111111111 Current A/D control', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.26_111111111111_current_a_d_control', @@ -339,7 +333,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.0', 'friendly_name': '29.111111111111 Latch 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_0', @@ -389,7 +382,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.1', 'friendly_name': '29.111111111111 Latch 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_1', @@ -439,7 +431,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.2', 'friendly_name': '29.111111111111 Latch 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_2', @@ -489,7 +480,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.3', 'friendly_name': '29.111111111111 Latch 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_3', @@ -539,7 +529,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.4', 'friendly_name': '29.111111111111 Latch 4', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_4', @@ -589,7 +578,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.5', 'friendly_name': '29.111111111111 Latch 5', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_5', @@ -639,7 +627,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.6', 'friendly_name': '29.111111111111 Latch 6', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_6', @@ -689,7 +676,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/latch.7', 'friendly_name': '29.111111111111 Latch 7', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_latch_7', @@ -739,7 +725,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.0', 'friendly_name': '29.111111111111 Programmed input-output 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_0', @@ -789,7 +774,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.1', 'friendly_name': '29.111111111111 Programmed input-output 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_1', @@ -839,7 +823,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.2', 'friendly_name': '29.111111111111 Programmed input-output 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_2', @@ -889,7 +872,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.3', 'friendly_name': '29.111111111111 Programmed input-output 3', - 'raw_value': None, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_3', @@ -939,7 +921,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.4', 'friendly_name': '29.111111111111 Programmed input-output 4', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_4', @@ -989,7 +970,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.5', 'friendly_name': '29.111111111111 Programmed input-output 5', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_5', @@ -1039,7 +1019,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.6', 'friendly_name': '29.111111111111 Programmed input-output 6', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_6', @@ -1089,7 +1068,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/29.111111111111/PIO.7', 'friendly_name': '29.111111111111 Programmed input-output 7', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.29_111111111111_programmed_input_output_7', @@ -1139,7 +1117,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/PIO.A', 'friendly_name': '3A.111111111111 Programmed input-output A', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.3a_111111111111_programmed_input_output_a', @@ -1189,7 +1166,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/3A.111111111111/PIO.B', 'friendly_name': '3A.111111111111 Programmed input-output B', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.3a_111111111111_programmed_input_output_b', @@ -1239,7 +1215,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/A6.111111111111/IAD', 'friendly_name': 'A6.111111111111 Current A/D control', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.a6_111111111111_current_a_d_control', @@ -1289,7 +1264,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.0', 'friendly_name': 'EF.111111111112 Leaf sensor 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_0', @@ -1339,7 +1313,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.1', 'friendly_name': 'EF.111111111112 Leaf sensor 1', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_1', @@ -1389,7 +1362,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.2', 'friendly_name': 'EF.111111111112 Leaf sensor 2', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_2', @@ -1439,7 +1411,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_leaf.3', 'friendly_name': 'EF.111111111112 Leaf sensor 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_leaf_sensor_3', @@ -1489,7 +1460,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.0', 'friendly_name': 'EF.111111111112 Moisture sensor 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_0', @@ -1539,7 +1509,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.1', 'friendly_name': 'EF.111111111112 Moisture sensor 1', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_1', @@ -1589,7 +1558,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.2', 'friendly_name': 'EF.111111111112 Moisture sensor 2', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_2', @@ -1639,7 +1607,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111112/moisture/is_moisture.3', 'friendly_name': 'EF.111111111112 Moisture sensor 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111112_moisture_sensor_3', @@ -1689,7 +1656,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.0', 'friendly_name': 'EF.111111111113 Hub branch 0', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_0', @@ -1739,7 +1705,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.1', 'friendly_name': 'EF.111111111113 Hub branch 1', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_1', @@ -1789,7 +1754,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.2', 'friendly_name': 'EF.111111111113 Hub branch 2', - 'raw_value': 1.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_2', @@ -1839,7 +1803,6 @@ 'attributes': ReadOnlyDict({ 'device_file': '/EF.111111111113/hub/branch.3', 'friendly_name': 'EF.111111111113 Hub branch 3', - 'raw_value': 0.0, }), 'context': , 'entity_id': 'switch.ef_111111111113_hub_branch_3', From 124e7cf4c8fa2b1e368f0b7ec849cd7f4f7a40a2 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Aug 2025 15:38:50 +0200 Subject: [PATCH 16/17] Add support for tuya ywcgq category (liquid level) (#150096) Thanks @joostlek / @frenck --- homeassistant/components/tuya/const.py | 7 + homeassistant/components/tuya/number.py | 26 + homeassistant/components/tuya/sensor.py | 19 + homeassistant/components/tuya/strings.json | 26 + tests/components/tuya/__init__.py | 10 +- .../tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json | 148 ++++++ .../tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json | 33 +- .../tuya/snapshots/test_number.ambr | 468 ++++++++++++++++++ .../tuya/snapshots/test_sensor.ambr | 312 ++++++++++++ 9 files changed, 1031 insertions(+), 18 deletions(-) create mode 100644 tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index e5a37d272eff2..38661d548a754 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -222,6 +222,7 @@ class DPCode(StrEnum): HUMIDITY_OUTDOOR_3 = "humidity_outdoor_3" # Outdoor humidity HUMIDITY_SET = "humidity_set" # Humidity setting HUMIDITY_VALUE = "humidity_value" # Humidity + INSTALLATION_HEIGHT = "installation_height" IPC_WORK_MODE = "ipc_work_mode" LED_TYPE_1 = "led_type_1" LED_TYPE_2 = "led_type_2" @@ -232,12 +233,18 @@ class DPCode(StrEnum): LEVEL_CURRENT = "level_current" LIGHT = "light" # Light LIGHT_MODE = "light_mode" + LIQUID_DEPTH = "liquid_depth" + LIQUID_DEPTH_MAX = "liquid_depth_max" + LIQUID_LEVEL_PERCENT = "liquid_level_percent" + LIQUID_STATE = "liquid_state" LOCK = "lock" # Lock / Child lock MASTER_MODE = "master_mode" # alarm mode MASTER_STATE = "master_state" # alarm state MACH_OPERATE = "mach_operate" MANUAL_FEED = "manual_feed" MATERIAL = "material" # Material + MAX_SET = "max_set" + MINI_SET = "mini_set" MODE = "mode" # Working mode / Mode MOODLIGHTING = "moodlighting" # Mood light MOTION_RECORD = "motion_record" diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index e7988adfafbda..88216ae3d069c 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -339,6 +339,32 @@ entity_category=EntityCategory.CONFIG, ), ), + # Tank Level Sensor + # Note: Undocumented + "ywcgq": ( + NumberEntityDescription( + key=DPCode.MAX_SET, + translation_key="alarm_maximum", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.MINI_SET, + translation_key="alarm_minimum", + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.INSTALLATION_HEIGHT, + translation_key="installation_height", + device_class=NumberDeviceClass.DISTANCE, + entity_category=EntityCategory.CONFIG, + ), + NumberEntityDescription( + key=DPCode.LIQUID_DEPTH_MAX, + translation_key="maximum_liquid_depth", + device_class=NumberDeviceClass.DISTANCE, + entity_category=EntityCategory.CONFIG, + ), + ), # Vibration Sensor # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno "zd": ( diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 5ca6e1d77a033..9eb05186f6373 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -1301,6 +1301,25 @@ class TuyaSensorEntityDescription(SensorEntityDescription): ), *BATTERY_SENSORS, ), + # Tank Level Sensor + # Note: Undocumented + "ywcgq": ( + TuyaSensorEntityDescription( + key=DPCode.LIQUID_STATE, + translation_key="liquid_state", + ), + TuyaSensorEntityDescription( + key=DPCode.LIQUID_DEPTH, + translation_key="depth", + device_class=SensorDeviceClass.DISTANCE, + state_class=SensorStateClass.MEASUREMENT, + ), + TuyaSensorEntityDescription( + key=DPCode.LIQUID_LEVEL_PERCENT, + translation_key="liquid_level", + state_class=SensorStateClass.MEASUREMENT, + ), + ), # Vibration Sensor # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno "zd": BATTERY_SENSORS, diff --git a/homeassistant/components/tuya/strings.json b/homeassistant/components/tuya/strings.json index ee9548cdef903..d660c9c910dcb 100644 --- a/homeassistant/components/tuya/strings.json +++ b/homeassistant/components/tuya/strings.json @@ -213,6 +213,18 @@ }, "siren_duration": { "name": "Siren duration" + }, + "alarm_maximum": { + "name": "Alarm maximum" + }, + "alarm_minimum": { + "name": "Alarm minimum" + }, + "installation_height": { + "name": "Installation height" + }, + "maximum_liquid_depth": { + "name": "Maximum liquid depth" } }, "select": { @@ -711,6 +723,20 @@ "charging": "[%key:common::state::charging%]", "charge_done": "Charge done" } + }, + "liquid_state": { + "name": "Liquid state", + "state": { + "normal": "[%key:common::state::normal%]", + "lower_alarm": "[%key:common::state::low%]", + "upper_alarm": "[%key:common::state::high%]" + } + }, + "liquid_depth": { + "name": "Liquid depth" + }, + "liquid_level": { + "name": "Liquid level" } }, "switch": { diff --git a/tests/components/tuya/__init__.py b/tests/components/tuya/__init__.py index f4f99a7df6ada..e68c2a73d551b 100644 --- a/tests/components/tuya/__init__.py +++ b/tests/components/tuya/__init__.py @@ -434,9 +434,15 @@ Platform.BINARY_SENSOR, Platform.SENSOR, ], + "ywcgq_h8lvyoahr6s6aybf": [ + # https://github.com/home-assistant/core/issues/145932 + Platform.NUMBER, + Platform.SENSOR, + ], "ywcgq_wtzwyhkev3b4ubns": [ - # https://community.home-assistant.io/t/something-is-wrong-with-tuya-tank-level-sensors-with-the-new-official-integration/689321 - # not (yet) supported + # https://github.com/home-assistant/core/issues/103818 + Platform.NUMBER, + Platform.SENSOR, ], "zndb_ze8faryrxr0glqnn": [ # https://github.com/home-assistant/core/issues/138372 diff --git a/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json b/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json new file mode 100644 index 0000000000000..8b1cff0c77329 --- /dev/null +++ b/tests/components/tuya/fixtures/ywcgq_h8lvyoahr6s6aybf.json @@ -0,0 +1,148 @@ +{ + "endpoint": "https://apigw.tuyaeu.com", + "terminal_id": "REDACTED", + "mqtt_connected": true, + "disabled_by": null, + "disabled_polling": false, + "id": "bf3d16d38b17d7034ddxd4", + "name": "Rainwater Tank Level", + "category": "ywcgq", + "product_id": "h8lvyoahr6s6aybf", + "product_name": "Tank A Level", + "online": true, + "sub": false, + "time_zone": "+01:00", + "active_time": "2025-05-31T09:55:19+00:00", + "create_time": "2025-05-31T09:55:19+00:00", + "update_time": "2025-05-31T09:55:19+00:00", + "function": { + "max_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mini_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "upper_switch": { + "type": "Boolean", + "value": {} + }, + "installation_height": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 3000, + "scale": 3, + "step": 1 + } + }, + "liquid_depth_max": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 2700, + "scale": 3, + "step": 1 + } + } + }, + "status_range": { + "liquid_state": { + "type": "Enum", + "value": { + "range": ["normal", "lower_alarm", "upper_alarm"] + } + }, + "liquid_depth": { + "type": "Integer", + "value": { + "unit": "m", + "min": 0, + "max": 10000, + "scale": 3, + "step": 1 + } + }, + "max_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "mini_set": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + }, + "upper_switch": { + "type": "Boolean", + "value": {} + }, + "installation_height": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 3000, + "scale": 3, + "step": 1 + } + }, + "liquid_depth_max": { + "type": "Integer", + "value": { + "unit": "m", + "min": 100, + "max": 2700, + "scale": 3, + "step": 1 + } + }, + "liquid_level_percent": { + "type": "Integer", + "value": { + "unit": "%", + "min": 0, + "max": 100, + "scale": 0, + "step": 1 + } + } + }, + "status": { + "liquid_state": "normal", + "liquid_depth": 455, + "max_set": 90, + "mini_set": 10, + "upper_switch": false, + "installation_height": 1350, + "liquid_depth_max": 100, + "liquid_level_percent": 36 + }, + "set_up": false, + "support_local": true +} diff --git a/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json b/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json index f724ffe164ff5..52eda664345ca 100644 --- a/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json +++ b/tests/components/tuya/fixtures/ywcgq_wtzwyhkev3b4ubns.json @@ -1,20 +1,22 @@ { - "endpoint": "https://apigw.tuyaeu.com", - "terminal_id": "REDACTED", + "endpoint": "https://openapi.tuyaus.com", + "auth_type": 0, + "country_code": "1", + "app_type": "tuyaSmart", "mqtt_connected": true, "disabled_by": null, "disabled_polling": false, - "id": "bf27a4********368f4w", - "name": "Nivel del tanque A", + "name": "House Water Level", + "model": "EPT_Ultrasonic level sensor", "category": "ywcgq", "product_id": "wtzwyhkev3b4ubns", "product_name": "Tank A Level", "online": true, "sub": false, - "time_zone": "+01:00", - "active_time": "2024-01-05T10:22:24+00:00", - "create_time": "2024-01-05T10:22:24+00:00", - "update_time": "2024-01-05T10:22:24+00:00", + "time_zone": "-06:00", + "active_time": "2023-11-02T22:48:03+00:00", + "create_time": "2023-11-02T22:48:03+00:00", + "update_time": "2023-11-09T13:32:38+00:00", "function": { "max_set": { "type": "Integer", @@ -126,14 +128,13 @@ } }, "status": { - "liquid_state": "normal", - "liquid_depth": 77, + "liquid_state": "upper_alarm", + "liquid_depth": 42, "max_set": 100, - "mini_set": 10, - "installation_height": 980, - "liquid_depth_max": 140, - "liquid_level_percent": 97 + "mini_set": 0, + "installation_height": 560, + "liquid_depth_max": 100, + "liquid_level_percent": 100 }, - "set_up": false, - "support_local": true + "terminal_id": "REDACTED" } diff --git a/tests/components/tuya/snapshots/test_number.ambr b/tests/components/tuya/snapshots/test_number.ambr index 7ab6af0b8879d..b5d6224ecea2b 100644 --- a/tests/components/tuya/snapshots/test_number.ambr +++ b/tests/components/tuya/snapshots/test_number.ambr @@ -817,3 +817,471 @@ 'state': '-1.5', }) # --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_maximum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_alarm_maximum', + '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': 'Alarm maximum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_maximum', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4max_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_maximum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Alarm maximum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_alarm_maximum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '90.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_minimum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_alarm_minimum', + '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': 'Alarm minimum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_minimum', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4mini_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_alarm_minimum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Alarm minimum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_alarm_minimum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '10.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_installation_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 3.0, + 'min': 0.1, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_installation_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Installation height', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'installation_height', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4installation_height', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_installation_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Rainwater Tank Level Installation height', + 'max': 3.0, + 'min': 0.1, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_installation_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '1.35', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_maximum_liquid_depth-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.7, + 'min': 0.1, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.rainwater_tank_level_maximum_liquid_depth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum liquid depth', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_liquid_depth', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_depth_max', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][number.rainwater_tank_level_maximum_liquid_depth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Rainwater Tank Level Maximum liquid depth', + 'max': 2.7, + 'min': 0.1, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.rainwater_tank_level_maximum_liquid_depth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_maximum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_alarm_maximum', + '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': 'Alarm maximum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_maximum', + 'unique_id': 'tuya.mocked_device_idmax_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_maximum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Alarm maximum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.house_water_level_alarm_maximum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_minimum-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_alarm_minimum', + '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': 'Alarm minimum', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'alarm_minimum', + 'unique_id': 'tuya.mocked_device_idmini_set', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_alarm_minimum-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Alarm minimum', + 'max': 100.0, + 'min': 0.0, + 'mode': , + 'step': 1.0, + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'number.house_water_level_alarm_minimum', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_installation_height-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.5, + 'min': 0.2, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_installation_height', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Installation height', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'installation_height', + 'unique_id': 'tuya.mocked_device_idinstallation_height', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_installation_height-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'House Water Level Installation height', + 'max': 2.5, + 'min': 0.2, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.house_water_level_installation_height', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.56', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_maximum_liquid_depth-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'max': 2.4, + 'min': 0.1, + 'mode': , + 'step': 0.001, + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'number', + 'entity_category': , + 'entity_id': 'number.house_water_level_maximum_liquid_depth', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Maximum liquid depth', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'maximum_liquid_depth', + 'unique_id': 'tuya.mocked_device_idliquid_depth_max', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][number.house_water_level_maximum_liquid_depth-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'House Water Level Maximum liquid depth', + 'max': 2.4, + 'min': 0.1, + 'mode': , + 'step': 0.001, + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'number.house_water_level_maximum_liquid_depth', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.1', + }) +# --- diff --git a/tests/components/tuya/snapshots/test_sensor.ambr b/tests/components/tuya/snapshots/test_sensor.ambr index 1dcb262dfd568..fcd14667d36b2 100644 --- a/tests/components/tuya/snapshots/test_sensor.ambr +++ b/tests/components/tuya/snapshots/test_sensor.ambr @@ -4421,6 +4421,318 @@ 'state': 'low', }) # --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rainwater_tank_level_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'depth', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_depth', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'Rainwater Tank Level Distance', + 'state_class': , + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'sensor.rainwater_tank_level_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.455', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rainwater_tank_level_liquid_level', + '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': 'Liquid level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_level', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_level_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Liquid level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.rainwater_tank_level_liquid_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '36.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.rainwater_tank_level_liquid_state', + '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': 'Liquid state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_state', + 'unique_id': 'tuya.bf3d16d38b17d7034ddxd4liquid_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_h8lvyoahr6s6aybf][sensor.rainwater_tank_level_liquid_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'Rainwater Tank Level Liquid state', + }), + 'context': , + 'entity_id': 'sensor.rainwater_tank_level_liquid_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'normal', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_distance-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.house_water_level_distance', + 'has_entity_name': True, + 'hidden_by': None, + 'icon': None, + 'id': , + 'labels': set({ + }), + 'name': None, + 'options': dict({ + 'sensor': dict({ + 'suggested_display_precision': 2, + }), + }), + 'original_device_class': , + 'original_icon': None, + 'original_name': 'Distance', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'depth', + 'unique_id': 'tuya.mocked_device_idliquid_depth', + 'unit_of_measurement': 'm', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_distance-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'device_class': 'distance', + 'friendly_name': 'House Water Level Distance', + 'state_class': , + 'unit_of_measurement': 'm', + }), + 'context': , + 'entity_id': 'sensor.house_water_level_distance', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '0.42', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_level-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': dict({ + 'state_class': , + }), + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.house_water_level_liquid_level', + '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': 'Liquid level', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_level', + 'unique_id': 'tuya.mocked_device_idliquid_level_percent', + 'unit_of_measurement': '%', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_level-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Liquid level', + 'state_class': , + 'unit_of_measurement': '%', + }), + 'context': , + 'entity_id': 'sensor.house_water_level_liquid_level', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': '100.0', + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_state-entry] + EntityRegistryEntrySnapshot({ + 'aliases': set({ + }), + 'area_id': None, + 'capabilities': None, + 'config_entry_id': , + 'config_subentry_id': , + 'device_class': None, + 'device_id': , + 'disabled_by': None, + 'domain': 'sensor', + 'entity_category': None, + 'entity_id': 'sensor.house_water_level_liquid_state', + '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': 'Liquid state', + 'platform': 'tuya', + 'previous_unique_id': None, + 'suggested_object_id': None, + 'supported_features': 0, + 'translation_key': 'liquid_state', + 'unique_id': 'tuya.mocked_device_idliquid_state', + 'unit_of_measurement': None, + }) +# --- +# name: test_platform_setup_and_discovery[ywcgq_wtzwyhkev3b4ubns][sensor.house_water_level_liquid_state-state] + StateSnapshot({ + 'attributes': ReadOnlyDict({ + 'friendly_name': 'House Water Level Liquid state', + }), + 'context': , + 'entity_id': 'sensor.house_water_level_liquid_state', + 'last_changed': , + 'last_reported': , + 'last_updated': , + 'state': 'upper_alarm', + }) +# --- # name: test_platform_setup_and_discovery[zndb_ze8faryrxr0glqnn][sensor.meter_phase_a_current-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ From 76ca9ce3a4a563aea1cb845e886b6793589e6b07 Mon Sep 17 00:00:00 2001 From: epenet <6771947+epenet@users.noreply.github.com> Date: Wed, 6 Aug 2025 17:12:32 +0200 Subject: [PATCH 17/17] Add comment to Tuya code for unsupported devices (#150125) Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com> --- homeassistant/components/tuya/__init__.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/homeassistant/components/tuya/__init__.py b/homeassistant/components/tuya/__init__.py index e8aa6bded220d..6ed8f0253ab47 100644 --- a/homeassistant/components/tuya/__init__.py +++ b/homeassistant/components/tuya/__init__.py @@ -165,6 +165,9 @@ async def async_setup_entry(hass: HomeAssistant, entry: TuyaConfigEntry) -> bool identifiers={(DOMAIN, device.id)}, manufacturer="Tuya", name=device.name, + # Note: the model is overridden via entity.device_info property + # when the entity is created. If no entities are generated, it will + # stay as unsupported model=f"{device.product_name} (unsupported)", model_id=device.product_id, )