diff --git a/.core_files.yaml b/.core_files.yaml index 2624c4432be522..5c6537aa236616 100644 --- a/.core_files.yaml +++ b/.core_files.yaml @@ -58,6 +58,7 @@ base_platforms: &base_platforms # Extra components that trigger the full suite components: &components - homeassistant/components/alexa/** + - homeassistant/components/analytics/** - homeassistant/components/application_credentials/** - homeassistant/components/assist_pipeline/** - homeassistant/components/auth/** diff --git a/CODEOWNERS b/CODEOWNERS index 0b6a1a8177f50f..59b72f3550b34f 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -410,6 +410,8 @@ build.json @home-assistant/supervisor /homeassistant/components/egardia/ @jeroenterheerdt /homeassistant/components/eheimdigital/ @autinerd /tests/components/eheimdigital/ @autinerd +/homeassistant/components/ekeybionyx/ @richardpolzer +/tests/components/ekeybionyx/ @richardpolzer /homeassistant/components/electrasmart/ @jafar-atili /tests/components/electrasmart/ @jafar-atili /homeassistant/components/electric_kiwi/ @mikey0000 @@ -972,8 +974,6 @@ build.json @home-assistant/supervisor /tests/components/moat/ @bdraco /homeassistant/components/mobile_app/ @home-assistant/core /tests/components/mobile_app/ @home-assistant/core -/homeassistant/components/modbus/ @janiversen -/tests/components/modbus/ @janiversen /homeassistant/components/modem_callerid/ @tkdrob /tests/components/modem_callerid/ @tkdrob /homeassistant/components/modern_forms/ @wonderslug diff --git a/homeassistant/components/acaia/coordinator.py b/homeassistant/components/acaia/coordinator.py index 629e61c395cafd..b42cbccaee50af 100644 --- a/homeassistant/components/acaia/coordinator.py +++ b/homeassistant/components/acaia/coordinator.py @@ -4,11 +4,9 @@ from datetime import timedelta import logging -from typing import cast from aioacaia.acaiascale import AcaiaScale from aioacaia.exceptions import AcaiaDeviceNotFound, AcaiaError -from bleak import BleakScanner from homeassistant.components.bluetooth import async_get_scanner from homeassistant.config_entries import ConfigEntry @@ -45,7 +43,7 @@ def __init__(self, hass: HomeAssistant, entry: AcaiaConfigEntry) -> None: name=entry.title, is_new_style_scale=entry.data[CONF_IS_NEW_STYLE_SCALE], notify_callback=self.async_update_listeners, - scanner=cast(BleakScanner, async_get_scanner(hass)), + scanner=async_get_scanner(hass), ) @property diff --git a/homeassistant/components/analytics/analytics.py b/homeassistant/components/analytics/analytics.py index b527c8ab9372ad..22e641c414a40e 100644 --- a/homeassistant/components/analytics/analytics.py +++ b/homeassistant/components/analytics/analytics.py @@ -546,12 +546,13 @@ async def async_devices_payload(hass: HomeAssistant) -> dict: if isinstance(integration, Integration) } - # Filter out custom integrations + # Filter out custom integrations and integrations that are not device or hub type integration_inputs = { domain: integration_info for domain, integration_info in integration_inputs.items() if (integration := integrations.get(domain)) is not None and integration.is_built_in + and integration.integration_type in ("device", "hub") } # Call integrations that implement the analytics platform diff --git a/homeassistant/components/automation/analytics.py b/homeassistant/components/automation/analytics.py deleted file mode 100644 index 06c9a553d8ae7c..00000000000000 --- a/homeassistant/components/automation/analytics.py +++ /dev/null @@ -1,24 +0,0 @@ -"""Analytics platform.""" - -from homeassistant.components.analytics import ( - AnalyticsInput, - AnalyticsModifications, - EntityAnalyticsModifications, -) -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er - - -async def async_modify_analytics( - hass: HomeAssistant, analytics_input: AnalyticsInput -) -> AnalyticsModifications: - """Modify the analytics.""" - ent_reg = er.async_get(hass) - - entities: dict[str, EntityAnalyticsModifications] = {} - for entity_id in analytics_input.entity_ids: - entity_entry = ent_reg.entities[entity_id] - if entity_entry.capabilities is not None: - entities[entity_id] = EntityAnalyticsModifications(capabilities=None) - - return AnalyticsModifications(entities=entities) diff --git a/homeassistant/components/conversation/manifest.json b/homeassistant/components/conversation/manifest.json index 8101f8c8b5f6f6..b3bc9b8c067e52 100644 --- a/homeassistant/components/conversation/manifest.json +++ b/homeassistant/components/conversation/manifest.json @@ -6,5 +6,5 @@ "documentation": "https://www.home-assistant.io/integrations/conversation", "integration_type": "entity", "quality_scale": "internal", - "requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.3"] + "requirements": ["hassil==3.2.0", "home-assistant-intents==2025.9.24"] } diff --git a/homeassistant/components/ekeybionyx/__init__.py b/homeassistant/components/ekeybionyx/__init__.py new file mode 100644 index 00000000000000..672824b811ac10 --- /dev/null +++ b/homeassistant/components/ekeybionyx/__init__.py @@ -0,0 +1,24 @@ +"""The Ekey Bionyx integration.""" + +from __future__ import annotations + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import Platform +from homeassistant.core import HomeAssistant + +PLATFORMS: list[Platform] = [Platform.EVENT] + + +type EkeyBionyxConfigEntry = ConfigEntry + + +async def async_setup_entry(hass: HomeAssistant, entry: EkeyBionyxConfigEntry) -> bool: + """Set up the Ekey Bionyx config entry.""" + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: EkeyBionyxConfigEntry) -> bool: + """Unload a config entry.""" + return await hass.config_entries.async_unload_platforms(entry, PLATFORMS) diff --git a/homeassistant/components/ekeybionyx/application_credentials.py b/homeassistant/components/ekeybionyx/application_credentials.py new file mode 100644 index 00000000000000..d6b7918af6bd0f --- /dev/null +++ b/homeassistant/components/ekeybionyx/application_credentials.py @@ -0,0 +1,14 @@ +"""application_credentials platform the Ekey Bionyx integration.""" + +from homeassistant.components.application_credentials import AuthorizationServer +from homeassistant.core import HomeAssistant + +from .const import OAUTH2_AUTHORIZE, OAUTH2_TOKEN + + +async def async_get_authorization_server(hass: HomeAssistant) -> AuthorizationServer: + """Return authorization server.""" + return AuthorizationServer( + authorize_url=OAUTH2_AUTHORIZE, + token_url=OAUTH2_TOKEN, + ) diff --git a/homeassistant/components/ekeybionyx/config_flow.py b/homeassistant/components/ekeybionyx/config_flow.py new file mode 100644 index 00000000000000..cdf0538eea50bd --- /dev/null +++ b/homeassistant/components/ekeybionyx/config_flow.py @@ -0,0 +1,271 @@ +"""Config flow for ekey bionyx.""" + +import asyncio +import json +import logging +import re +import secrets +from typing import Any, NotRequired, TypedDict + +import aiohttp +import ekey_bionyxpy +import voluptuous as vol + +from homeassistant.components.webhook import ( + async_generate_id as webhook_generate_id, + async_generate_path as webhook_generate_path, +) +from homeassistant.config_entries import ConfigFlowResult +from homeassistant.const import CONF_TOKEN, CONF_URL +from homeassistant.helpers import config_entry_oauth2_flow, config_validation as cv +from homeassistant.helpers.aiohttp_client import async_get_clientsession +from homeassistant.helpers.network import get_url +from homeassistant.helpers.selector import SelectOptionDict, SelectSelector + +from .const import API_URL, DOMAIN, INTEGRATION_NAME, SCOPE + +# Valid webhook name: starts with letter or underscore, contains letters, digits, spaces, dots, and underscores, does not end with space or dot +VALID_NAME_PATTERN = re.compile(r"^(?![\d\s])[\w\d \.]*[\w\d]$") + + +class ConfigFlowEkeyApi(ekey_bionyxpy.AbstractAuth): + """ekey bionyx authentication before a ConfigEntry exists. + + This implementation directly provides the token without supporting refresh. + """ + + def __init__( + self, + websession: aiohttp.ClientSession, + token: dict[str, Any], + ) -> None: + """Initialize ConfigFlowEkeyApi.""" + super().__init__(websession, API_URL) + self._token = token + + async def async_get_access_token(self) -> str: + """Return the token for the Ekey API.""" + return self._token["access_token"] + + +class EkeyFlowData(TypedDict): + """Type for Flow Data.""" + + api: NotRequired[ekey_bionyxpy.BionyxAPI] + system: NotRequired[ekey_bionyxpy.System] + systems: NotRequired[list[ekey_bionyxpy.System]] + + +class OAuth2FlowHandler( + config_entry_oauth2_flow.AbstractOAuth2FlowHandler, domain=DOMAIN +): + """Config flow to handle ekey bionyx OAuth2 authentication.""" + + DOMAIN = DOMAIN + + check_deletion_task: asyncio.Task[None] | None = None + + def __init__(self) -> None: + """Initialize OAuth2FlowHandler.""" + super().__init__() + self._data: EkeyFlowData = {} + + @property + def logger(self) -> logging.Logger: + """Return logger.""" + return logging.getLogger(__name__) + + @property + def extra_authorize_data(self) -> dict[str, Any]: + """Extra data that needs to be appended to the authorize url.""" + return {"scope": SCOPE} + + async def async_oauth_create_entry(self, data: dict[str, Any]) -> ConfigFlowResult: + """Start the user facing flow by initializing the API and getting the systems.""" + client = ConfigFlowEkeyApi(async_get_clientsession(self.hass), data[CONF_TOKEN]) + ap = ekey_bionyxpy.BionyxAPI(client) + self._data["api"] = ap + try: + system_res = await ap.get_systems() + except aiohttp.ClientResponseError: + return self.async_abort( + reason="cannot_connect", + description_placeholders={"ekeybionyx": INTEGRATION_NAME}, + ) + system = [s for s in system_res if s.own_system] + if len(system) == 0: + return self.async_abort(reason="no_own_systems") + self._data["systems"] = system + if len(system) == 1: + # skipping choose_system since there is only one + self._data["system"] = system[0] + return await self.async_step_check_system(user_input=None) + return await self.async_step_choose_system(user_input=None) + + async def async_step_choose_system( + self, user_input: dict[str, Any] | None + ) -> ConfigFlowResult: + """Dialog to choose System if multiple systems are present.""" + if user_input is None: + options: list[SelectOptionDict] = [ + {"value": s.system_id, "label": s.system_name} + for s in self._data["systems"] + ] + data_schema = {vol.Required("system"): SelectSelector({"options": options})} + return self.async_show_form( + step_id="choose_system", + data_schema=vol.Schema(data_schema), + description_placeholders={"ekeybionyx": INTEGRATION_NAME}, + ) + self._data["system"] = [ + s for s in self._data["systems"] if s.system_id == user_input["system"] + ][0] + return await self.async_step_check_system(user_input=None) + + async def async_step_check_system( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Check if system has open webhooks.""" + system = self._data["system"] + await self.async_set_unique_id(system.system_id) + self._abort_if_unique_id_configured() + + if ( + system.function_webhook_quotas["free"] == 0 + and system.function_webhook_quotas["used"] == 0 + ): + return self.async_abort( + reason="no_available_webhooks", + description_placeholders={"ekeybionyx": INTEGRATION_NAME}, + ) + + if system.function_webhook_quotas["used"] > 0: + return await self.async_step_delete_webhooks() + return await self.async_step_webhooks(user_input=None) + + async def async_step_webhooks( + self, user_input: dict[str, Any] | None + ) -> ConfigFlowResult: + """Dialog to setup webhooks.""" + system = self._data["system"] + + errors: dict[str, str] | None = None + if user_input is not None: + errors = {} + for key, webhook_name in user_input.items(): + if key == CONF_URL: + continue + if not re.match(VALID_NAME_PATTERN, webhook_name): + errors.update({key: "invalid_name"}) + try: + cv.url(user_input[CONF_URL]) + except vol.Invalid: + errors[CONF_URL] = "invalid_url" + if set(user_input) == {CONF_URL}: + errors["base"] = "no_webhooks_provided" + + if not errors: + webhook_data = [ + { + "auth": secrets.token_hex(32), + "name": webhook_name, + "webhook_id": webhook_generate_id(), + } + for key, webhook_name in user_input.items() + if key != CONF_URL + ] + for webhook in webhook_data: + wh_def: ekey_bionyxpy.WebhookData = { + "integrationName": "Home Assistant", + "functionName": webhook["name"], + "locationName": "Home Assistant", + "definition": { + "url": user_input[CONF_URL] + + webhook_generate_path(webhook["webhook_id"]), + "authentication": {"apiAuthenticationType": "None"}, + "securityLevel": "AllowHttp", + "method": "Post", + "body": { + "contentType": "application/json", + "content": json.dumps({"auth": webhook["auth"]}), + }, + }, + } + webhook["ekey_id"] = (await system.add_webhook(wh_def)).webhook_id + return self.async_create_entry( + title=self._data["system"].system_name, + data={"webhooks": webhook_data}, + ) + + data_schema: dict[Any, Any] = { + vol.Optional(f"webhook{i + 1}"): vol.All(str, vol.Length(max=50)) + for i in range(self._data["system"].function_webhook_quotas["free"]) + } + data_schema[vol.Required(CONF_URL)] = str + return self.async_show_form( + step_id="webhooks", + data_schema=self.add_suggested_values_to_schema( + vol.Schema(data_schema), + { + CONF_URL: get_url( + self.hass, + allow_ip=True, + prefer_external=False, + ) + } + | (user_input or {}), + ), + errors=errors, + description_placeholders={ + "webhooks_available": str( + self._data["system"].function_webhook_quotas["free"] + ), + "ekeybionyx": INTEGRATION_NAME, + }, + ) + + async def async_step_delete_webhooks( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Form to delete Webhooks.""" + if user_input is None: + return self.async_show_form(step_id="delete_webhooks") + for webhook in await self._data["system"].get_webhooks(): + await webhook.delete() + return await self.async_step_wait_for_deletion(user_input=None) + + async def async_step_wait_for_deletion( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Wait for webhooks to be deleted in another flow.""" + uncompleted_task: asyncio.Task[None] | None = None + + if not self.check_deletion_task: + self.check_deletion_task = self.hass.async_create_task( + self.async_check_deletion_status() + ) + if not self.check_deletion_task.done(): + progress_action = "check_deletion_status" + uncompleted_task = self.check_deletion_task + if uncompleted_task: + return self.async_show_progress( + step_id="wait_for_deletion", + description_placeholders={"ekeybionyx": INTEGRATION_NAME}, + progress_action=progress_action, + progress_task=uncompleted_task, + ) + self.check_deletion_task = None + return self.async_show_progress_done(next_step_id="webhooks") + + async def async_check_deletion_status(self) -> None: + """Check if webhooks have been deleted.""" + while True: + self._data["systems"] = await self._data["api"].get_systems() + self._data["system"] = [ + s + for s in self._data["systems"] + if s.system_id == self._data["system"].system_id + ][0] + if self._data["system"].function_webhook_quotas["used"] == 0: + break + await asyncio.sleep(5) diff --git a/homeassistant/components/ekeybionyx/const.py b/homeassistant/components/ekeybionyx/const.py new file mode 100644 index 00000000000000..eaf5b87f874ab5 --- /dev/null +++ b/homeassistant/components/ekeybionyx/const.py @@ -0,0 +1,13 @@ +"""Constants for the Ekey Bionyx integration.""" + +import logging + +DOMAIN = "ekeybionyx" +INTEGRATION_NAME = "ekey bionyx" + +LOGGER = logging.getLogger(__package__) + +OAUTH2_AUTHORIZE = "https://ekeybionyxprod.b2clogin.com/ekeybionyxprod.onmicrosoft.com/B2C_1_sign_in_v2/oauth2/v2.0/authorize" +OAUTH2_TOKEN = "https://ekeybionyxprod.b2clogin.com/ekeybionyxprod.onmicrosoft.com/B2C_1_sign_in_v2/oauth2/v2.0/token" +API_URL = "https://api.bionyx.io/3rd-party/api" +SCOPE = "https://ekeybionyxprod.onmicrosoft.com/3rd-party-api/api-access" diff --git a/homeassistant/components/ekeybionyx/event.py b/homeassistant/components/ekeybionyx/event.py new file mode 100644 index 00000000000000..b847637465b880 --- /dev/null +++ b/homeassistant/components/ekeybionyx/event.py @@ -0,0 +1,70 @@ +"""Event platform for ekey bionyx integration.""" + +from aiohttp.hdrs import METH_POST +from aiohttp.web import Request, Response + +from homeassistant.components.event import EventDeviceClass, EventEntity +from homeassistant.components.webhook import ( + async_register as webhook_register, + async_unregister as webhook_unregister, +) +from homeassistant.core import HomeAssistant, callback +from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback + +from . import EkeyBionyxConfigEntry +from .const import DOMAIN + + +async def async_setup_entry( + hass: HomeAssistant, + entry: EkeyBionyxConfigEntry, + async_add_entities: AddConfigEntryEntitiesCallback, +) -> None: + """Set up Ekey event.""" + async_add_entities(EkeyEvent(data) for data in entry.data["webhooks"]) + + +class EkeyEvent(EventEntity): + """Ekey Event.""" + + _attr_device_class = EventDeviceClass.BUTTON + _attr_event_types = ["event happened"] + + def __init__( + self, + data: dict[str, str], + ) -> None: + """Initialise a Ekey event entity.""" + self._attr_name = data["name"] + self._attr_unique_id = data["ekey_id"] + self._webhook_id = data["webhook_id"] + self._auth = data["auth"] + + @callback + def _async_handle_event(self) -> None: + """Handle the webhook event.""" + self._trigger_event("event happened") + self.async_write_ha_state() + + async def async_added_to_hass(self) -> None: + """Register callbacks with your device API/library.""" + + async def async_webhook_handler( + hass: HomeAssistant, webhook_id: str, request: Request + ) -> Response | None: + if (await request.json())["auth"] == self._auth: + self._async_handle_event() + return None + + webhook_register( + self.hass, + DOMAIN, + f"Ekey {self._attr_name}", + self._webhook_id, + async_webhook_handler, + allowed_methods=[METH_POST], + ) + + async def async_will_remove_from_hass(self) -> None: + """Unregister Webhook.""" + webhook_unregister(self.hass, self._webhook_id) diff --git a/homeassistant/components/ekeybionyx/manifest.json b/homeassistant/components/ekeybionyx/manifest.json new file mode 100644 index 00000000000000..a53dc13b9936ca --- /dev/null +++ b/homeassistant/components/ekeybionyx/manifest.json @@ -0,0 +1,11 @@ +{ + "domain": "ekeybionyx", + "name": "ekey bionyx", + "codeowners": ["@richardpolzer"], + "config_flow": true, + "dependencies": ["application_credentials", "http"], + "documentation": "https://www.home-assistant.io/integrations/ekeybionyx", + "iot_class": "local_push", + "quality_scale": "bronze", + "requirements": ["ekey-bionyxpy==1.0.0"] +} diff --git a/homeassistant/components/ekeybionyx/quality_scale.yaml b/homeassistant/components/ekeybionyx/quality_scale.yaml new file mode 100644 index 00000000000000..13122e56adf5c8 --- /dev/null +++ b/homeassistant/components/ekeybionyx/quality_scale.yaml @@ -0,0 +1,92 @@ +rules: + # Bronze + action-setup: + status: exempt + comment: This integration does not provide actions. + appropriate-polling: + status: exempt + comment: This integration does not poll. + brands: done + common-modules: done + config-flow: done + config-flow-test-coverage: done + dependency-transparency: done + docs-actions: + status: exempt + comment: This integration does not provide actions. + docs-high-level-description: done + docs-installation-instructions: done + docs-removal-instructions: done + entity-event-setup: done + entity-unique-id: done + has-entity-name: done + runtime-data: + status: exempt + comment: This integration does not connect to any device or service. + test-before-configure: done + test-before-setup: + status: exempt + comment: This integration does not connect to any device or service. + unique-config-entry: done + + # Silver + action-exceptions: + status: exempt + comment: This integration does not provide actions. + config-entry-unloading: done + docs-configuration-parameters: todo + docs-installation-parameters: todo + entity-unavailable: + status: exempt + comment: This integration has no way of knowing if the fingerprint reader is offline. + integration-owner: done + log-when-unavailable: + status: exempt + comment: This integration has no way of knowing if the fingerprint reader is offline. + parallel-updates: + status: exempt + comment: This integration does not poll. + reauthentication-flow: + status: exempt + comment: This integration does not store the tokens. + test-coverage: todo + + # Gold + devices: + status: exempt + comment: This integration does not connect to any device or service. + diagnostics: todo + discovery-update-info: + status: exempt + comment: This integration does not support discovery. + discovery: + status: exempt + comment: This integration does not support discovery. + docs-data-update: todo + docs-examples: todo + docs-known-limitations: done + docs-supported-devices: todo + docs-supported-functions: todo + docs-troubleshooting: todo + docs-use-cases: todo + dynamic-devices: + status: exempt + comment: This integration does not connect to any device or service. + entity-category: todo + entity-device-class: done + entity-disabled-by-default: + status: exempt + comment: This integration has no entities that should be disabled by default. + entity-translations: todo + exception-translations: todo + icon-translations: todo + reconfiguration-flow: todo + repair-issues: todo + stale-devices: + status: exempt + comment: This integration does not connect to any device or service. + + # Platinum + async-dependency: done + inject-websession: done + strict-typing: todo diff --git a/homeassistant/components/ekeybionyx/strings.json b/homeassistant/components/ekeybionyx/strings.json new file mode 100644 index 00000000000000..14ad5de5aa49cd --- /dev/null +++ b/homeassistant/components/ekeybionyx/strings.json @@ -0,0 +1,66 @@ +{ + "config": { + "step": { + "pick_implementation": { + "title": "[%key:common::config_flow::title::oauth2_pick_implementation%]" + }, + "choose_system": { + "data": { + "system": "System" + }, + "data_description": { + "system": "System the event entities should be set up for." + }, + "description": "Please select the {ekeybionyx} system which you want to connect to Home Assistant." + }, + "webhooks": { + "description": "Please name your event entities. These event entities will be mapped as functions in the {ekeybionyx} app. You can configure up to {webhooks_available} event entities. Leaving a name empty will skip the setup of that event entity.", + "data": { + "webhook1": "Event entity 1", + "webhook2": "Event entity 2", + "webhook3": "Event entity 3", + "webhook4": "Event entity 4", + "webhook5": "Event entity 5", + "url": "Home Assistant URL" + }, + "data_description": { + "webhook1": "Name of event entity 1 that will be mapped into a function", + "webhook2": "Name of event entity 2 that will be mapped into a function", + "webhook3": "Name of event entity 3 that will be mapped into a function", + "webhook4": "Name of event entity 4 that will be mapped into a function", + "webhook5": "Name of event entity 5 that will be mapped into a function", + "url": "Home Assistant instance URL which can be reached from the fingerprint controller" + } + }, + "delete_webhooks": { + "description": "This system has already been connected to Home Assistant. If you continue, the previously configured functions will be deleted." + } + }, + "progress": { + "check_deletion_status": "Please open the {ekeybionyx} app and confirm the deletion of the functions." + }, + "error": { + "invalid_name": "Name is invalid", + "invalid_url": "URL is invalid", + "no_webhooks_provided": "No event names provided" + }, + "abort": { + "already_configured": "[%key:common::config_flow::abort::already_configured_account%]", + "already_in_progress": "[%key:common::config_flow::abort::already_in_progress%]", + "oauth_error": "[%key:common::config_flow::abort::oauth2_error%]", + "oauth_failed": "[%key:common::config_flow::abort::oauth2_failed%]", + "oauth_timeout": "[%key:common::config_flow::abort::oauth2_timeout%]", + "oauth_unauthorized": "[%key:common::config_flow::abort::oauth2_unauthorized%]", + "missing_configuration": "[%key:common::config_flow::abort::oauth2_missing_configuration%]", + "authorize_url_timeout": "[%key:common::config_flow::abort::oauth2_authorize_url_timeout%]", + "no_url_available": "[%key:common::config_flow::abort::oauth2_no_url_available%]", + "user_rejected_authorize": "[%key:common::config_flow::abort::oauth2_user_rejected_authorize%]", + "no_available_webhooks": "There are no available webhooks in the {ekeybionyx} system. Please delete some and try again.", + "no_own_systems": "Your account does not have admin access to any systems.", + "cannot_connect": "Connection to {ekeybionyx} failed. Please check your Internet connection and try again." + }, + "create_entry": { + "default": "[%key:common::config_flow::create_entry::authenticated%]" + } + } +} diff --git a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py index 6ea568890f9899..98a2fb2f881a02 100644 --- a/homeassistant/components/homeassistant_hardware/firmware_config_flow.py +++ b/homeassistant/components/homeassistant_hardware/firmware_config_flow.py @@ -28,7 +28,7 @@ OptionsFlow, ) from homeassistant.core import callback -from homeassistant.data_entry_flow import AbortFlow +from homeassistant.data_entry_flow import AbortFlow, progress_step from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers.aiohttp_client import async_get_clientsession from homeassistant.helpers.hassio import is_hassio @@ -72,8 +72,6 @@ class BaseFirmwareInstallFlow(ConfigEntryBaseFlow, ABC): """Base flow to install firmware.""" ZIGBEE_BAUDRATE = 115200 # Default, subclasses may override - _failed_addon_name: str - _failed_addon_reason: str _picked_firmware_type: PickedFirmwareType def __init__(self, *args: Any, **kwargs: Any) -> None: @@ -85,8 +83,6 @@ def __init__(self, *args: Any, **kwargs: Any) -> None: self._hardware_name: str = "unknown" # To be set in a subclass self._zigbee_integration = ZigbeeIntegration.ZHA - self.addon_install_task: asyncio.Task | None = None - self.addon_start_task: asyncio.Task | None = None self.addon_uninstall_task: asyncio.Task | None = None self.firmware_install_task: asyncio.Task[None] | None = None self.installing_firmware_name: str | None = None @@ -486,18 +482,6 @@ async def async_step_install_zigbee_firmware( """Install Zigbee firmware.""" raise NotImplementedError - async def async_step_addon_operation_failed( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Abort when add-on installation or start failed.""" - return self.async_abort( - reason=self._failed_addon_reason, - description_placeholders={ - **self._get_translation_placeholders(), - "addon_name": self._failed_addon_name, - }, - ) - async def async_step_pre_confirm_zigbee( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -561,6 +545,12 @@ async def async_step_install_thread_firmware( """Install Thread firmware.""" raise NotImplementedError + @progress_step( + description_placeholders=lambda self: { + **self._get_translation_placeholders(), + "addon_name": get_otbr_addon_manager(self.hass).addon_name, + } + ) async def async_step_install_otbr_addon( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: @@ -570,70 +560,43 @@ async def async_step_install_otbr_addon( _LOGGER.debug("OTBR addon info: %s", addon_info) - if not self.addon_install_task: - self.addon_install_task = self.hass.async_create_task( - addon_manager.async_install_addon_waiting(), - "OTBR addon install", - ) - - if not self.addon_install_task.done(): - return self.async_show_progress( - step_id="install_otbr_addon", - progress_action="install_addon", + try: + await addon_manager.async_install_addon_waiting() + except AddonError as err: + _LOGGER.error(err) + raise AbortFlow( + "addon_install_failed", description_placeholders={ **self._get_translation_placeholders(), "addon_name": addon_manager.addon_name, }, - progress_task=self.addon_install_task, - ) - - try: - await self.addon_install_task - except AddonError as err: - _LOGGER.error(err) - self._failed_addon_name = addon_manager.addon_name - self._failed_addon_reason = "addon_install_failed" - return self.async_show_progress_done(next_step_id="addon_operation_failed") - finally: - self.addon_install_task = None + ) from err - return self.async_show_progress_done(next_step_id="finish_thread_installation") + return await self.async_step_finish_thread_installation() + @progress_step( + description_placeholders=lambda self: { + **self._get_translation_placeholders(), + "addon_name": get_otbr_addon_manager(self.hass).addon_name, + } + ) async def async_step_start_otbr_addon( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Configure OTBR to point to the SkyConnect and run the addon.""" - otbr_manager = get_otbr_addon_manager(self.hass) - - if not self.addon_start_task: - self.addon_start_task = self.hass.async_create_task( - self._configure_and_start_otbr_addon() - ) - - if not self.addon_start_task.done(): - return self.async_show_progress( - step_id="start_otbr_addon", - progress_action="start_otbr_addon", + try: + await self._configure_and_start_otbr_addon() + except AddonError as err: + _LOGGER.error(err) + raise AbortFlow( + "addon_start_failed", description_placeholders={ **self._get_translation_placeholders(), - "addon_name": otbr_manager.addon_name, + "addon_name": get_otbr_addon_manager(self.hass).addon_name, }, - progress_task=self.addon_start_task, - ) - - try: - await self.addon_start_task - except (AddonError, AbortFlow) as err: - _LOGGER.error(err) - self._failed_addon_name = otbr_manager.addon_name - self._failed_addon_reason = ( - err.reason if isinstance(err, AbortFlow) else "addon_start_failed" - ) - return self.async_show_progress_done(next_step_id="addon_operation_failed") - finally: - self.addon_start_task = None + ) from err - return self.async_show_progress_done(next_step_id="pre_confirm_otbr") + return await self.async_step_pre_confirm_otbr() async def async_step_pre_confirm_otbr( self, user_input: dict[str, Any] | None = None @@ -641,20 +604,6 @@ async def async_step_pre_confirm_otbr( """Pre-confirm OTBR setup.""" # This step is necessary to prevent `user_input` from being passed through - return await self.async_step_confirm_otbr() - - async def async_step_confirm_otbr( - self, user_input: dict[str, Any] | None = None - ) -> ConfigFlowResult: - """Confirm OTBR setup.""" - assert self._device is not None - - if user_input is None: - return self.async_show_form( - step_id="confirm_otbr", - description_placeholders=self._get_translation_placeholders(), - ) - # OTBR discovery is done automatically via hassio return self._async_flow_finished() diff --git a/homeassistant/components/homekit_controller/utils.py b/homeassistant/components/homekit_controller/utils.py index ac436ce27a491f..9d04576ec28a0c 100644 --- a/homeassistant/components/homekit_controller/utils.py +++ b/homeassistant/components/homekit_controller/utils.py @@ -63,7 +63,7 @@ async def async_get_controller(hass: HomeAssistant) -> Controller: controller = Controller( async_zeroconf_instance=async_zeroconf_instance, - bleak_scanner_instance=bleak_scanner_instance, # type: ignore[arg-type] + bleak_scanner_instance=bleak_scanner_instance, char_cache=char_cache, ) diff --git a/homeassistant/components/modbus/manifest.json b/homeassistant/components/modbus/manifest.json index 429633224239e6..190766bf7965b0 100644 --- a/homeassistant/components/modbus/manifest.json +++ b/homeassistant/components/modbus/manifest.json @@ -1,7 +1,7 @@ { "domain": "modbus", "name": "Modbus", - "codeowners": ["@janiversen"], + "codeowners": [], "documentation": "https://www.home-assistant.io/integrations/modbus", "iot_class": "local_polling", "loggers": ["pymodbus"], diff --git a/homeassistant/components/neo/__init__.py b/homeassistant/components/neo/__init__.py new file mode 100644 index 00000000000000..613f57c070317b --- /dev/null +++ b/homeassistant/components/neo/__init__.py @@ -0,0 +1 @@ +"""Neo virtual integration.""" diff --git a/homeassistant/components/neo/manifest.json b/homeassistant/components/neo/manifest.json new file mode 100644 index 00000000000000..9f934a6030988e --- /dev/null +++ b/homeassistant/components/neo/manifest.json @@ -0,0 +1,6 @@ +{ + "domain": "neo", + "name": "Neo", + "integration_type": "virtual", + "supported_by": "shelly" +} diff --git a/homeassistant/components/renault/manifest.json b/homeassistant/components/renault/manifest.json index 9fe01c5b95296a..82b6f82867d17f 100644 --- a/homeassistant/components/renault/manifest.json +++ b/homeassistant/components/renault/manifest.json @@ -8,5 +8,5 @@ "iot_class": "cloud_polling", "loggers": ["renault_api"], "quality_scale": "silver", - "requirements": ["renault-api==0.4.0"] + "requirements": ["renault-api==0.4.1"] } diff --git a/homeassistant/components/smartthings/climate.py b/homeassistant/components/smartthings/climate.py index 98581af9fe8928..28c1c9c3782571 100644 --- a/homeassistant/components/smartthings/climate.py +++ b/homeassistant/components/smartthings/climate.py @@ -14,6 +14,9 @@ ATTR_TARGET_TEMP_LOW, DEFAULT_MAX_TEMP, DEFAULT_MIN_TEMP, + PRESET_BOOST, + PRESET_NONE, + PRESET_SLEEP, SWING_BOTH, SWING_HORIZONTAL, SWING_OFF, @@ -97,6 +100,19 @@ "heat": HVACMode.HEAT, } +PRESET_MODE_TO_HA = { + "off": PRESET_NONE, + "windFree": "wind_free", + "sleep": PRESET_SLEEP, + "windFreeSleep": "wind_free_sleep", + "speed": PRESET_BOOST, + "quiet": "quiet", + "longWind": "long_wind", + "smart": "smart", +} + +HA_MODE_TO_PRESET_MODE = {v: k for k, v in PRESET_MODE_TO_HA.items()} + HA_MODE_TO_HEAT_PUMP_AC_MODE = {v: k for k, v in HEAT_PUMP_AC_MODE_TO_HA.items()} WIND = "wind" @@ -362,6 +378,7 @@ class SmartThingsAirConditioner(SmartThingsEntity, ClimateEntity): """Define a SmartThings Air Conditioner.""" _attr_name = None + _attr_translation_key = "air_conditioner" def __init__(self, client: SmartThings, device: FullDevice) -> None: """Init the class.""" @@ -582,9 +599,7 @@ def preset_mode(self) -> str | None: Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Attribute.AC_OPTIONAL_MODE, ) - # Return the mode if it is in the supported modes - if self._attr_preset_modes and mode in self._attr_preset_modes: - return mode + return PRESET_MODE_TO_HA.get(mode) return None def _determine_preset_modes(self) -> list[str] | None: @@ -594,8 +609,16 @@ def _determine_preset_modes(self) -> list[str] | None: Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Attribute.SUPPORTED_AC_OPTIONAL_MODE, ) - if supported_modes: - return supported_modes + modes = [] + for mode in supported_modes: + if (ha_mode := PRESET_MODE_TO_HA.get(mode)) is not None: + modes.append(ha_mode) + else: + _LOGGER.warning( + "Unknown preset mode: %s, please report at https://github.com/home-assistant/core/issues", + mode, + ) + return modes return None async def async_set_preset_mode(self, preset_mode: str) -> None: @@ -603,7 +626,7 @@ async def async_set_preset_mode(self, preset_mode: str) -> None: await self.execute_device_command( Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Command.SET_AC_OPTIONAL_MODE, - argument=preset_mode, + argument=HA_MODE_TO_PRESET_MODE[preset_mode], ) def _determine_hvac_modes(self) -> list[HVACMode]: diff --git a/homeassistant/components/smartthings/strings.json b/homeassistant/components/smartthings/strings.json index 0c9cc394fb3925..244324bb1b4c02 100644 --- a/homeassistant/components/smartthings/strings.json +++ b/homeassistant/components/smartthings/strings.json @@ -78,6 +78,21 @@ "name": "[%key:common::action::stop%]" } }, + "climate": { + "air_conditioner": { + "state_attributes": { + "preset_mode": { + "state": { + "wind_free": "WindFree", + "wind_free_sleep": "WindFree sleep", + "quiet": "Quiet", + "long_wind": "Long wind", + "smart": "Smart" + } + } + } + } + }, "event": { "button": { "state": { diff --git a/homeassistant/components/synology_dsm/__init__.py b/homeassistant/components/synology_dsm/__init__.py index 7146d42136eec6..d52547980720a1 100644 --- a/homeassistant/components/synology_dsm/__init__.py +++ b/homeassistant/components/synology_dsm/__init__.py @@ -4,6 +4,7 @@ from itertools import chain import logging +from typing import TYPE_CHECKING from synology_dsm.api.surveillance_station import SynoSurveillanceStation from synology_dsm.api.surveillance_station.camera import SynoCamera @@ -177,10 +178,12 @@ async def async_remove_config_entry_device( """Remove synology_dsm config entry from a device.""" data = entry.runtime_data api = data.api - assert api.information is not None + if TYPE_CHECKING: + assert api.information is not None serial = api.information.serial storage = api.storage - assert storage is not None + if TYPE_CHECKING: + assert storage is not None all_cameras: list[SynoCamera] = [] if api.surveillance_station is not None: # get_all_cameras does not do I/O diff --git a/homeassistant/components/synology_dsm/binary_sensor.py b/homeassistant/components/synology_dsm/binary_sensor.py index 1ae5fa90760525..3af87f9756d264 100644 --- a/homeassistant/components/synology_dsm/binary_sensor.py +++ b/homeassistant/components/synology_dsm/binary_sensor.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +from typing import TYPE_CHECKING from synology_dsm.api.core.security import SynoCoreSecurity from synology_dsm.api.storage.storage import SynoStorage @@ -68,7 +69,8 @@ async def async_setup_entry( data = entry.runtime_data api = data.api coordinator = data.coordinator_central - assert api.storage is not None + if TYPE_CHECKING: + assert api.storage is not None entities: list[SynoDSMSecurityBinarySensor | SynoDSMStorageBinarySensor] = [ SynoDSMSecurityBinarySensor(api, coordinator, description) @@ -121,7 +123,8 @@ def available(self) -> bool: @property def extra_state_attributes(self) -> dict[str, str]: """Return security checks details.""" - assert self._api.security is not None + if TYPE_CHECKING: + assert self._api.security is not None return self._api.security.status_by_check diff --git a/homeassistant/components/synology_dsm/button.py b/homeassistant/components/synology_dsm/button.py index 79297b1f1b4fb3..9c99f3a4c2a4ce 100644 --- a/homeassistant/components/synology_dsm/button.py +++ b/homeassistant/components/synology_dsm/button.py @@ -5,7 +5,7 @@ from collections.abc import Callable, Coroutine from dataclasses import dataclass import logging -from typing import Any, Final +from typing import TYPE_CHECKING, Any, Final from homeassistant.components.button import ( ButtonDeviceClass, @@ -72,8 +72,9 @@ def __init__( """Initialize the Synology DSM binary_sensor entity.""" self.entity_description = description self.syno_api = api - assert api.network is not None - assert api.information is not None + if TYPE_CHECKING: + assert api.network is not None + assert api.information is not None self._attr_name = f"{api.network.hostname} {description.name}" self._attr_unique_id = f"{api.information.serial}_{description.key}" self._attr_device_info = DeviceInfo( @@ -82,7 +83,8 @@ def __init__( async def async_press(self) -> None: """Triggers the Synology DSM button press service.""" - assert self.syno_api.network is not None + if TYPE_CHECKING: + assert self.syno_api.network is not None LOGGER.debug( "Trigger %s for %s", self.entity_description.key, diff --git a/homeassistant/components/synology_dsm/camera.py b/homeassistant/components/synology_dsm/camera.py index f393b8efb55269..56183804e5f126 100644 --- a/homeassistant/components/synology_dsm/camera.py +++ b/homeassistant/components/synology_dsm/camera.py @@ -4,6 +4,7 @@ from dataclasses import dataclass import logging +from typing import TYPE_CHECKING from synology_dsm.api.surveillance_station import SynoCamera, SynoSurveillanceStation from synology_dsm.exceptions import ( @@ -94,7 +95,8 @@ def camera_data(self) -> SynoCamera: def device_info(self) -> DeviceInfo: """Return the device information.""" information = self._api.information - assert information is not None + if TYPE_CHECKING: + assert information is not None return DeviceInfo( identifiers={(DOMAIN, f"{information.serial}_{self.camera_data.id}")}, name=self.camera_data.name, @@ -129,7 +131,8 @@ def _handle_signal(url: str) -> None: _LOGGER.debug("Update stream URL for camera %s", self.camera_data.name) self.stream.update_source(url) - assert self.platform.config_entry + if TYPE_CHECKING: + assert self.platform.config_entry self.async_on_remove( async_dispatcher_connect( self.hass, @@ -153,7 +156,8 @@ async def async_camera_image( ) if not self.available: return None - assert self._api.surveillance_station is not None + if TYPE_CHECKING: + assert self._api.surveillance_station is not None try: return await self._api.surveillance_station.get_camera_image( self.entity_description.camera_id, self.snapshot_quality @@ -187,7 +191,8 @@ async def async_enable_motion_detection(self) -> None: "SynoDSMCamera.enable_motion_detection(%s)", self.camera_data.name, ) - assert self._api.surveillance_station is not None + if TYPE_CHECKING: + assert self._api.surveillance_station is not None await self._api.surveillance_station.enable_motion_detection( self.entity_description.camera_id ) @@ -198,7 +203,8 @@ async def async_disable_motion_detection(self) -> None: "SynoDSMCamera.disable_motion_detection(%s)", self.camera_data.name, ) - assert self._api.surveillance_station is not None + if TYPE_CHECKING: + assert self._api.surveillance_station is not None await self._api.surveillance_station.disable_motion_detection( self.entity_description.camera_id ) diff --git a/homeassistant/components/synology_dsm/coordinator.py b/homeassistant/components/synology_dsm/coordinator.py index dd97dedf65e2e2..c2fa275c7de9a9 100644 --- a/homeassistant/components/synology_dsm/coordinator.py +++ b/homeassistant/components/synology_dsm/coordinator.py @@ -6,7 +6,7 @@ from dataclasses import dataclass from datetime import timedelta import logging -from typing import Any, Concatenate +from typing import TYPE_CHECKING, Any, Concatenate from synology_dsm.api.surveillance_station.camera import SynoCamera from synology_dsm.exceptions import ( @@ -110,14 +110,16 @@ def __init__( async def async_setup(self) -> None: """Set up the coordinator initial data.""" info = await self.api.dsm.surveillance_station.get_info() - assert info is not None + if TYPE_CHECKING: + assert info is not None self.version = info["data"]["CMSMinVersion"] @async_re_login_on_expired async def _async_update_data(self) -> dict[str, dict[str, Any]]: """Fetch all data from api.""" surveillance_station = self.api.surveillance_station - assert surveillance_station is not None + if TYPE_CHECKING: + assert surveillance_station is not None return { "switches": { "home_mode": bool(await surveillance_station.get_home_mode_status()) @@ -161,7 +163,8 @@ def __init__( async def _async_update_data(self) -> dict[str, dict[int, SynoCamera]]: """Fetch all camera data from api.""" surveillance_station = self.api.surveillance_station - assert surveillance_station is not None + if TYPE_CHECKING: + assert surveillance_station is not None current_data: dict[int, SynoCamera] = { camera.id: camera for camera in surveillance_station.get_all_cameras() } diff --git a/homeassistant/components/synology_dsm/entity.py b/homeassistant/components/synology_dsm/entity.py index 85269b9c480119..3ffbcce54665a2 100644 --- a/homeassistant/components/synology_dsm/entity.py +++ b/homeassistant/components/synology_dsm/entity.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.helpers.device_registry import DeviceInfo from homeassistant.helpers.entity import EntityDescription @@ -47,8 +47,9 @@ def __init__( self._api = api information = api.information network = api.network - assert information is not None - assert network is not None + if TYPE_CHECKING: + assert information is not None + assert network is not None self._attr_unique_id: str = ( f"{information.serial}_{description.api_key}:{description.key}" @@ -94,14 +95,17 @@ def __init__( information = api.information network = api.network external_usb = api.external_usb - assert information is not None - assert storage is not None - assert network is not None + if TYPE_CHECKING: + assert information is not None + assert storage is not None + assert network is not None if "volume" in description.key: - assert self._device_id is not None + if TYPE_CHECKING: + assert self._device_id is not None volume = storage.get_volume(self._device_id) - assert volume is not None + if TYPE_CHECKING: + assert volume is not None # Volume does not have a name self._device_name = volume["id"].replace("_", " ").capitalize() self._device_manufacturer = "Synology" @@ -114,17 +118,20 @@ def __init__( .replace("shr", "SHR") ) elif "disk" in description.key: - assert self._device_id is not None + if TYPE_CHECKING: + assert self._device_id is not None disk = storage.get_disk(self._device_id) - assert disk is not None + if TYPE_CHECKING: + assert disk is not None self._device_name = disk["name"] self._device_manufacturer = disk["vendor"] self._device_model = disk["model"].strip() self._device_firmware = disk["firm"] self._device_type = disk["diskType"] elif "device" in description.key: - assert self._device_id is not None - assert external_usb is not None + if TYPE_CHECKING: + assert self._device_id is not None + assert external_usb is not None for device in external_usb.get_devices.values(): if device.device_name == self._device_id: self._device_name = device.device_name @@ -133,8 +140,9 @@ def __init__( self._device_type = device.device_type break elif "partition" in description.key: - assert self._device_id is not None - assert external_usb is not None + if TYPE_CHECKING: + assert self._device_id is not None + assert external_usb is not None for device in external_usb.get_devices.values(): for partition in device.device_partitions.values(): if partition.partition_title == self._device_id: diff --git a/homeassistant/components/synology_dsm/media_source.py b/homeassistant/components/synology_dsm/media_source.py index 9f9f308df5da1c..94edef603ce661 100644 --- a/homeassistant/components/synology_dsm/media_source.py +++ b/homeassistant/components/synology_dsm/media_source.py @@ -4,6 +4,7 @@ from logging import getLogger import mimetypes +from typing import TYPE_CHECKING from aiohttp import web from synology_dsm.api.photos import SynoPhotosAlbum, SynoPhotosItem @@ -121,9 +122,11 @@ async def _async_build_diskstations( DOMAIN, identifier.unique_id ) ) - assert entry + if TYPE_CHECKING: + assert entry diskstation = entry.runtime_data - assert diskstation.api.photos is not None + if TYPE_CHECKING: + assert diskstation.api.photos is not None if identifier.album_id is None: # Get Albums @@ -131,7 +134,8 @@ async def _async_build_diskstations( albums = await diskstation.api.photos.get_albums() except SynologyDSMException: return [] - assert albums is not None + if TYPE_CHECKING: + assert albums is not None ret = [ BrowseMediaSource( @@ -190,7 +194,8 @@ async def _async_build_diskstations( ) except SynologyDSMException: return [] - assert album_items is not None + if TYPE_CHECKING: + assert album_items is not None ret = [] for album_item in album_items: @@ -249,7 +254,8 @@ async def async_get_thumbnail( self, item: SynoPhotosItem, diskstation: SynologyDSMData ) -> str | None: """Get thumbnail.""" - assert diskstation.api.photos is not None + if TYPE_CHECKING: + assert diskstation.api.photos is not None try: thumbnail = await diskstation.api.photos.get_item_thumbnail_url(item) @@ -290,9 +296,11 @@ async def get( DOMAIN, source_dir_id ) ) - assert entry + if TYPE_CHECKING: + assert entry diskstation = entry.runtime_data - assert diskstation.api.photos is not None + if TYPE_CHECKING: + assert diskstation.api.photos is not None item = SynoPhotosItem(image_id, "", "", "", cache_key, "xl", shared, passphrase) try: if passphrase: diff --git a/homeassistant/components/synology_dsm/sensor.py b/homeassistant/components/synology_dsm/sensor.py index a9f66e4762ea7c..dd46fa33c3a221 100644 --- a/homeassistant/components/synology_dsm/sensor.py +++ b/homeassistant/components/synology_dsm/sensor.py @@ -4,7 +4,7 @@ from dataclasses import dataclass from datetime import datetime, timedelta -from typing import cast +from typing import TYPE_CHECKING, cast from synology_dsm.api.core.external_usb import ( SynoCoreExternalUSB, @@ -345,7 +345,8 @@ async def async_setup_entry( api = data.api coordinator = data.coordinator_central storage = api.storage - assert storage is not None + if TYPE_CHECKING: + assert storage is not None known_usb_devices: set[str] = set() def _check_usb_devices() -> None: @@ -504,7 +505,8 @@ def __init__( def native_value(self) -> StateType: """Return the state.""" external_usb = self._api.external_usb - assert external_usb is not None + if TYPE_CHECKING: + assert external_usb is not None if "device" in self.entity_description.key: for device in external_usb.get_devices.values(): if device.device_name == self._device_id: @@ -523,6 +525,22 @@ def native_value(self) -> StateType: return attr # type: ignore[no-any-return] + @property + def available(self) -> bool: + """Return True if entity is available.""" + external_usb = self._api.external_usb + assert external_usb is not None + if "device" in self.entity_description.key: + for device in external_usb.get_devices.values(): + if device.device_name == self._device_id: + return super().available + elif "partition" in self.entity_description.key: + for device in external_usb.get_devices.values(): + for partition in device.device_partitions.values(): + if partition.partition_title == self._device_id: + return super().available + return False + class SynoDSMInfoSensor(SynoDSMSensor): """Representation a Synology information sensor.""" diff --git a/homeassistant/components/synology_dsm/services.py b/homeassistant/components/synology_dsm/services.py index 9522361d500b2a..ad0615eaa56620 100644 --- a/homeassistant/components/synology_dsm/services.py +++ b/homeassistant/components/synology_dsm/services.py @@ -3,7 +3,7 @@ from __future__ import annotations import logging -from typing import cast +from typing import TYPE_CHECKING, cast from synology_dsm.exceptions import SynologyDSMException @@ -27,7 +27,8 @@ async def _service_handler(call: ServiceCall) -> None: entry: SynologyDSMConfigEntry | None = ( call.hass.config_entries.async_entry_for_domain_unique_id(DOMAIN, serial) ) - assert entry + if TYPE_CHECKING: + assert entry dsm_device = entry.runtime_data elif len(dsm_devices) == 1: dsm_device = next(iter(dsm_devices.values())) diff --git a/homeassistant/components/synology_dsm/switch.py b/homeassistant/components/synology_dsm/switch.py index 91863ff3a260c4..8be6dedd8ca7dc 100644 --- a/homeassistant/components/synology_dsm/switch.py +++ b/homeassistant/components/synology_dsm/switch.py @@ -4,7 +4,7 @@ from dataclasses import dataclass import logging -from typing import Any +from typing import TYPE_CHECKING, Any from synology_dsm.api.surveillance_station import SynoSurveillanceStation @@ -45,7 +45,8 @@ async def async_setup_entry( """Set up the Synology NAS switch.""" data = entry.runtime_data if coordinator := data.coordinator_switches: - assert coordinator.version is not None + if TYPE_CHECKING: + assert coordinator.version is not None async_add_entities( SynoDSMSurveillanceHomeModeToggle( data.api, coordinator.version, coordinator, description @@ -79,8 +80,9 @@ def is_on(self) -> bool: async def async_turn_on(self, **kwargs: Any) -> None: """Turn on Home mode.""" - assert self._api.surveillance_station is not None - assert self._api.information + if TYPE_CHECKING: + assert self._api.surveillance_station is not None + assert self._api.information _LOGGER.debug( "SynoDSMSurveillanceHomeModeToggle.turn_on(%s)", self._api.information.serial, @@ -90,8 +92,9 @@ async def async_turn_on(self, **kwargs: Any) -> None: async def async_turn_off(self, **kwargs: Any) -> None: """Turn off Home mode.""" - assert self._api.surveillance_station is not None - assert self._api.information + if TYPE_CHECKING: + assert self._api.surveillance_station is not None + assert self._api.information _LOGGER.debug( "SynoDSMSurveillanceHomeModeToggle.turn_off(%s)", self._api.information.serial, @@ -107,9 +110,10 @@ def available(self) -> bool: @property def device_info(self) -> DeviceInfo: """Return the device information.""" - assert self._api.surveillance_station is not None - assert self._api.information is not None - assert self._api.network is not None + if TYPE_CHECKING: + assert self._api.surveillance_station is not None + assert self._api.information is not None + assert self._api.network is not None return DeviceInfo( identifiers={ ( diff --git a/homeassistant/components/synology_dsm/update.py b/homeassistant/components/synology_dsm/update.py index 3048a38cb9c5c6..6b421f639e7c86 100644 --- a/homeassistant/components/synology_dsm/update.py +++ b/homeassistant/components/synology_dsm/update.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import Final +from typing import TYPE_CHECKING, Final from synology_dsm.api.core.upgrade import SynoCoreUpgrade from yarl import URL @@ -63,13 +63,15 @@ def available(self) -> bool: @property def installed_version(self) -> str | None: """Version installed and in use.""" - assert self._api.information is not None + if TYPE_CHECKING: + assert self._api.information is not None return self._api.information.version_string @property def latest_version(self) -> str | None: """Latest version available for install.""" - assert self._api.upgrade is not None + if TYPE_CHECKING: + assert self._api.upgrade is not None if not self._api.upgrade.update_available: return self.installed_version return self._api.upgrade.available_version @@ -77,8 +79,9 @@ def latest_version(self) -> str | None: @property def release_url(self) -> str | None: """URL to the full release notes of the latest version available.""" - assert self._api.information is not None - assert self._api.upgrade is not None + if TYPE_CHECKING: + assert self._api.information is not None + assert self._api.upgrade is not None if (details := self._api.upgrade.available_version_details) is None: return None diff --git a/homeassistant/components/template/analytics.py b/homeassistant/components/template/analytics.py deleted file mode 100644 index e4db2c5c70a63b..00000000000000 --- a/homeassistant/components/template/analytics.py +++ /dev/null @@ -1,43 +0,0 @@ -"""Analytics platform.""" - -from homeassistant.components.analytics import ( - AnalyticsInput, - AnalyticsModifications, - EntityAnalyticsModifications, -) -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant, split_entity_id -from homeassistant.helpers import entity_registry as er - -FILTERED_PLATFORM_CAPABILITY: dict[str, str] = { - Platform.FAN: "preset_modes", - Platform.SELECT: "options", -} - - -async def async_modify_analytics( - hass: HomeAssistant, analytics_input: AnalyticsInput -) -> AnalyticsModifications: - """Modify the analytics.""" - ent_reg = er.async_get(hass) - - entities: dict[str, EntityAnalyticsModifications] = {} - for entity_id in analytics_input.entity_ids: - platform = split_entity_id(entity_id)[0] - if platform not in FILTERED_PLATFORM_CAPABILITY: - continue - - entity_entry = ent_reg.entities[entity_id] - if entity_entry.capabilities is not None: - filtered_capability = FILTERED_PLATFORM_CAPABILITY[platform] - if filtered_capability not in entity_entry.capabilities: - continue - - capabilities = dict(entity_entry.capabilities) - capabilities[filtered_capability] = len(capabilities[filtered_capability]) - - entities[entity_id] = EntityAnalyticsModifications( - capabilities=capabilities - ) - - return AnalyticsModifications(entities=entities) diff --git a/homeassistant/components/tuya/alarm_control_panel.py b/homeassistant/components/tuya/alarm_control_panel.py index d08a3bef7ce568..43105af0362ca4 100644 --- a/homeassistant/components/tuya/alarm_control_panel.py +++ b/homeassistant/components/tuya/alarm_control_panel.py @@ -19,7 +19,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData from .util import get_dpcode @@ -57,12 +57,8 @@ class State(StrEnum): } -# All descriptions can be found here: -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -ALARM: dict[str, tuple[TuyaAlarmControlPanelEntityDescription, ...]] = { - # Alarm Host - # https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf - "mal": ( +ALARM: dict[DeviceCategory, tuple[TuyaAlarmControlPanelEntityDescription, ...]] = { + DeviceCategory.MAL: ( TuyaAlarmControlPanelEntityDescription( key=DPCode.MASTER_MODE, master_state=DPCode.MASTER_STATE, @@ -79,23 +75,23 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya alarm dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya siren.""" entities: list[TuyaAlarmEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := ALARM.get(device.category): entities.extend( - TuyaAlarmEntity(device, hass_data.manager, description) + TuyaAlarmEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/binary_sensor.py b/homeassistant/components/tuya/binary_sensor.py index 08645b49e4ccb5..9a4be708880dd0 100644 --- a/homeassistant/components/tuya/binary_sensor.py +++ b/homeassistant/components/tuya/binary_sensor.py @@ -18,7 +18,7 @@ from homeassistant.util.json import json_loads from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity @@ -48,11 +48,8 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): # All descriptions can be found here. Mostly the Boolean data types in the # default status set of each category (that don't have a set instruction) # end up being a binary sensor. -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -BINARY_SENSORS: dict[str, tuple[TuyaBinarySensorEntityDescription, ...]] = { - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( +BINARY_SENSORS: dict[DeviceCategory, tuple[TuyaBinarySensorEntityDescription, ...]] = { + DeviceCategory.CO2BJ: ( TuyaBinarySensorEntityDescription( key=DPCode.CO2_STATE, device_class=BinarySensorDeviceClass.SAFETY, @@ -60,9 +57,7 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): ), TAMPER_BINARY_SENSOR, ), - # CO Detector - # https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v - "cobj": ( + DeviceCategory.COBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.CO_STATE, device_class=BinarySensorDeviceClass.SAFETY, @@ -75,9 +70,7 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): ), TAMPER_BINARY_SENSOR, ), - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha - "cs": ( + DeviceCategory.CS: ( TuyaBinarySensorEntityDescription( key="tankfull", dpcode=DPCode.FAULT, @@ -103,18 +96,14 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): translation_key="wet", ), ), - # Smart Pet Feeder - # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld - "cwwsq": ( + DeviceCategory.CWWSQ: ( TuyaBinarySensorEntityDescription( key=DPCode.FEED_STATE, translation_key="feeding", on_value="feeding", ), ), - # Multi-functional Sensor - # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 - "dgnbj": ( + DeviceCategory.DGNBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.GAS_SENSOR_STATE, device_class=BinarySensorDeviceClass.GAS, @@ -177,18 +166,14 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): ), TAMPER_BINARY_SENSOR, ), - # Human Presence Sensor - # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs - "hps": ( + DeviceCategory.HPS: ( TuyaBinarySensorEntityDescription( key=DPCode.PRESENCE_STATE, device_class=BinarySensorDeviceClass.OCCUPANCY, on_value={"presence", "small_move", "large_move", "peaceful"}, ), ), - # Formaldehyde Detector - # Note: Not documented - "jqbj": ( + DeviceCategory.JQBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.CH2O_STATE, device_class=BinarySensorDeviceClass.SAFETY, @@ -196,9 +181,7 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): ), TAMPER_BINARY_SENSOR, ), - # Methane Detector - # https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm - "jwbj": ( + DeviceCategory.JWBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.CH4_SENSOR_STATE, device_class=BinarySensorDeviceClass.GAS, @@ -206,9 +189,7 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): ), TAMPER_BINARY_SENSOR, ), - # Luminance Sensor - # https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 - "ldcg": ( + DeviceCategory.LDCG: ( TuyaBinarySensorEntityDescription( key=DPCode.TEMPER_ALARM, device_class=BinarySensorDeviceClass.TAMPER, @@ -216,18 +197,14 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): ), TAMPER_BINARY_SENSOR, ), - # Door and Window Controller - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9 - "mc": ( + DeviceCategory.MC: ( TuyaBinarySensorEntityDescription( key=DPCode.STATUS, device_class=BinarySensorDeviceClass.DOOR, on_value={"open", "opened"}, ), ), - # Door Window Sensor - # https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m - "mcs": ( + DeviceCategory.MCS: ( TuyaBinarySensorEntityDescription( key=DPCode.DOORCONTACT_STATE, device_class=BinarySensorDeviceClass.DOOR, @@ -238,18 +215,14 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): ), TAMPER_BINARY_SENSOR, ), - # Access Control - # https://developer.tuya.com/en/docs/iot/s?id=Kb0o2xhlkxbet - "mk": ( + DeviceCategory.MK: ( TuyaBinarySensorEntityDescription( key=DPCode.CLOSED_OPENED_KIT, device_class=BinarySensorDeviceClass.LOCK, on_value={"AQAB"}, ), ), - # PIR Detector - # https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 - "pir": ( + DeviceCategory.PIR: ( TuyaBinarySensorEntityDescription( key=DPCode.PIR, device_class=BinarySensorDeviceClass.MOTION, @@ -257,9 +230,7 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): ), TAMPER_BINARY_SENSOR, ), - # PM2.5 Sensor - # https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu - "pm2.5": ( + DeviceCategory.PM2_5: ( TuyaBinarySensorEntityDescription( key=DPCode.PM25_STATE, device_class=BinarySensorDeviceClass.SAFETY, @@ -267,12 +238,8 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): ), TAMPER_BINARY_SENSOR, ), - # Temperature and Humidity Sensor with External Probe - # New undocumented category qxj, see https://github.com/home-assistant/core/issues/136472 - "qxj": (TAMPER_BINARY_SENSOR,), - # Gas Detector - # https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw - "rqbj": ( + DeviceCategory.QXJ: (TAMPER_BINARY_SENSOR,), + DeviceCategory.RQBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.GAS_SENSOR_STATUS, device_class=BinarySensorDeviceClass.GAS, @@ -285,18 +252,14 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): ), TAMPER_BINARY_SENSOR, ), - # Siren Alarm - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sgbj": ( + DeviceCategory.SGBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.CHARGE_STATE, device_class=BinarySensorDeviceClass.BATTERY_CHARGING, ), TAMPER_BINARY_SENSOR, ), - # Water Detector - # https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli - "sj": ( + DeviceCategory.SJ: ( TuyaBinarySensorEntityDescription( key=DPCode.WATERSENSOR_STATE, device_class=BinarySensorDeviceClass.MOISTURE, @@ -304,18 +267,14 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): ), TAMPER_BINARY_SENSOR, ), - # Emergency Button - # https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy - "sos": ( + DeviceCategory.SOS: ( TuyaBinarySensorEntityDescription( key=DPCode.SOS_STATE, device_class=BinarySensorDeviceClass.SAFETY, ), TAMPER_BINARY_SENSOR, ), - # Volatile Organic Compound Sensor - # Note: Undocumented in cloud API docs, based on test device - "voc": ( + DeviceCategory.VOC: ( TuyaBinarySensorEntityDescription( key=DPCode.VOC_STATE, device_class=BinarySensorDeviceClass.SAFETY, @@ -323,9 +282,7 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): ), TAMPER_BINARY_SENSOR, ), - # Gateway control - # https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok - "wg2": ( + DeviceCategory.WG2: ( TuyaBinarySensorEntityDescription( key=DPCode.MASTER_STATE, device_class=BinarySensorDeviceClass.PROBLEM, @@ -333,39 +290,29 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): on_value="alarm", ), ), - # Thermostat - # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 - "wk": ( + DeviceCategory.WK: ( TuyaBinarySensorEntityDescription( key=DPCode.VALVE_STATE, translation_key="valve", on_value="open", ), ), - # Thermostatic Radiator Valve - # Not documented - "wkf": ( + DeviceCategory.WKF: ( TuyaBinarySensorEntityDescription( key=DPCode.WINDOW_STATE, device_class=BinarySensorDeviceClass.WINDOW, on_value="opened", ), ), - # Temperature and Humidity Sensor - # https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 - "wsdcg": (TAMPER_BINARY_SENSOR,), - # Pressure Sensor - # https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm - "ylcg": ( + DeviceCategory.WSDCG: (TAMPER_BINARY_SENSOR,), + DeviceCategory.YLCG: ( TuyaBinarySensorEntityDescription( key=DPCode.PRESSURE_STATE, on_value="alarm", ), TAMPER_BINARY_SENSOR, ), - # Smoke Detector - # https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 - "ywbj": ( + DeviceCategory.YWBJ: ( TuyaBinarySensorEntityDescription( key=DPCode.SMOKE_SENSOR_STATUS, device_class=BinarySensorDeviceClass.SMOKE, @@ -378,9 +325,7 @@ class TuyaBinarySensorEntityDescription(BinarySensorEntityDescription): ), TAMPER_BINARY_SENSOR, ), - # Vibration Sensor - # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno - "zd": ( + DeviceCategory.ZD: ( TuyaBinarySensorEntityDescription( key=f"{DPCode.SHOCK_STATE}_vibration", dpcode=DPCode.SHOCK_STATE, @@ -425,14 +370,14 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya binary sensor dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya binary sensor.""" entities: list[TuyaBinarySensorEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := BINARY_SENSORS.get(device.category): for description in descriptions: dpcode = description.dpcode or description.key @@ -448,7 +393,7 @@ def async_discover_device(device_ids: list[str]) -> None: entities.append( TuyaBinarySensorEntity( device, - hass_data.manager, + manager, description, mask, ) @@ -456,7 +401,7 @@ def async_discover_device(device_ids: list[str]) -> None: async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/button.py b/homeassistant/components/tuya/button.py index 928e584e77d6a6..013a02df048667 100644 --- a/homeassistant/components/tuya/button.py +++ b/homeassistant/components/tuya/button.py @@ -11,23 +11,17 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -# All descriptions can be found here. -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -BUTTONS: dict[str, tuple[ButtonEntityDescription, ...]] = { - # Wake Up Light II - # Not documented - "hxd": ( +BUTTONS: dict[DeviceCategory, tuple[ButtonEntityDescription, ...]] = { + DeviceCategory.HXD: ( ButtonEntityDescription( key=DPCode.SWITCH_USB6, translation_key="snooze", ), ), - # Robot Vacuum - # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo - "sd": ( + DeviceCategory.SD: ( ButtonEntityDescription( key=DPCode.RESET_DUSTER_CLOTH, translation_key="reset_duster_cloth", @@ -63,24 +57,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya buttons dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya buttons.""" entities: list[TuyaButtonEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := BUTTONS.get(device.category): entities.extend( - TuyaButtonEntity(device, hass_data.manager, description) + TuyaButtonEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/camera.py b/homeassistant/components/tuya/camera.py index 788a9bcc5c3ac8..93525c723da2e8 100644 --- a/homeassistant/components/tuya/camera.py +++ b/homeassistant/components/tuya/camera.py @@ -11,18 +11,12 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -# All descriptions can be found here: -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -CAMERAS: tuple[str, ...] = ( - # Smart Camera - Low power consumption camera - # Undocumented, see https://github.com/home-assistant/core/issues/132844 - "dghsxj", - # Smart Camera (including doorbells) - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sp", +CAMERAS: tuple[DeviceCategory, ...] = ( + DeviceCategory.DGHSXJ, + DeviceCategory.SP, ) @@ -32,20 +26,20 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya cameras dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya camera.""" entities: list[TuyaCameraEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if device.category in CAMERAS: - entities.append(TuyaCameraEntity(device, hass_data.manager)) + entities.append(TuyaCameraEntity(device, manager)) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/climate.py b/homeassistant/components/tuya/climate.py index ecfc96f1d67e95..ab1d8db16fa529 100644 --- a/homeassistant/components/tuya/climate.py +++ b/homeassistant/components/tuya/climate.py @@ -24,7 +24,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData from .util import get_dpcode @@ -48,40 +48,28 @@ class TuyaClimateEntityDescription(ClimateEntityDescription): switch_only_hvac_mode: HVACMode -CLIMATE_DESCRIPTIONS: dict[str, TuyaClimateEntityDescription] = { - # Electric Fireplace - # https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop - "dbl": TuyaClimateEntityDescription( +CLIMATE_DESCRIPTIONS: dict[DeviceCategory, TuyaClimateEntityDescription] = { + DeviceCategory.DBL: TuyaClimateEntityDescription( key="dbl", switch_only_hvac_mode=HVACMode.HEAT, ), - # Air conditioner - # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n - "kt": TuyaClimateEntityDescription( + DeviceCategory.KT: TuyaClimateEntityDescription( key="kt", switch_only_hvac_mode=HVACMode.COOL, ), - # Heater - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46epy4j82 - "qn": TuyaClimateEntityDescription( + DeviceCategory.QN: TuyaClimateEntityDescription( key="qn", switch_only_hvac_mode=HVACMode.HEAT, ), - # Heater - # https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx - "rs": TuyaClimateEntityDescription( + DeviceCategory.RS: TuyaClimateEntityDescription( key="rs", switch_only_hvac_mode=HVACMode.HEAT, ), - # Thermostat - # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 - "wk": TuyaClimateEntityDescription( + DeviceCategory.WK: TuyaClimateEntityDescription( key="wk", switch_only_hvac_mode=HVACMode.HEAT_COOL, ), - # Thermostatic Radiator Valve - # Not documented - "wkf": TuyaClimateEntityDescription( + DeviceCategory.WKF: TuyaClimateEntityDescription( key="wkf", switch_only_hvac_mode=HVACMode.HEAT, ), @@ -94,26 +82,26 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya climate dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya climate.""" entities: list[TuyaClimateEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if device and device.category in CLIMATE_DESCRIPTIONS: entities.append( TuyaClimateEntity( device, - hass_data.manager, + manager, CLIMATE_DESCRIPTIONS[device.category], hass.config.units.temperature_unit, ) ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/const.py b/homeassistant/components/tuya/const.py index 81ef495dabc88b..15849494602888 100644 --- a/homeassistant/components/tuya/const.py +++ b/homeassistant/components/tuya/const.py @@ -92,6 +92,463 @@ class DPType(StrEnum): STRING = "String" +class DeviceCategory(StrEnum): + """Tuya device categories. + + https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq + """ + + AMY = "amy" + """Massage chair""" + BGL = "bgl" + """Wall-hung boiler""" + BH = "bh" + """Smart kettle + + https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 + """ + BX = "bx" + """Refrigerator""" + BXX = "bxx" + """Safe box""" + CJKG = "cjkg" + """Scene switch""" + CKMKZQ = "ckmkzq" + """Garage door opener + + https://developer.tuya.com/en/docs/iot/categoryckmkzq?id=Kaiuz0ipcboee + """ + CKQDKG = "ckqdkg" + """Card switch""" + CL = "cl" + """Curtain + + https://developer.tuya.com/en/docs/iot/categorycl?id=Kaiuz1hnpo7df + """ + CLKG = "clkg" + """Curtain switch + + https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 + """ + CN = "cn" + """Milk dispenser""" + CO2BJ = "co2bj" + """CO2 detector + + https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy + """ + COBJ = "cobj" + """CO detector + + https://developer.tuya.com/en/docs/iot/categorycobj?id=Kaiuz3u1j6q1v + """ + CS = "cs" + """Dehumidifier + + https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha + """ + CWTSWSQ = "cwtswsq" + """Pet treat feeder""" + CWWQFSQ = "cwwqfsq" + """Pet ball thrower""" + CWWSQ = "cwwsq" + """Pet feeder + + https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld + """ + CWYSJ = "cwysj" + """Pet fountain""" + CZ = "cz" + """Socket""" + DBL = "dbl" + """Electric fireplace + + https://developer.tuya.com/en/docs/iot/f?id=Kacpeobojffop + """ + DC = "dc" + """String lights + + # https://developer.tuya.com/en/docs/iot/dc?id=Kaof7taxmvadu + """ + DCL = "dcl" + """Induction cooker""" + DD = "dd" + """Strip lights + + https://developer.tuya.com/en/docs/iot/dd?id=Kaof804aibg2l + """ + DGNBJ = "dgnbj" + """Multi-functional alarm + + https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 + """ + DJ = "dj" + """Light + + https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy + """ + DLQ = "dlq" + """Circuit breaker""" + DR = "dr" + """Electric blanket + + https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p + """ + DS = "ds" + """TV set""" + FS = "fs" + """Fan + + https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c + """ + FSD = "fsd" + """Ceiling fan light + + https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v + """ + FWD = "fwd" + """Ambiance light + + https://developer.tuya.com/en/docs/iot/ambient-light?id=Kaiuz06amhe6g + """ + GGQ = "ggq" + """Irrigator""" + GYD = "gyd" + """Motion sensor light + + https://developer.tuya.com/en/docs/iot/gyd?id=Kaof8a8hycfmy + """ + GYMS = "gyms" + """Business lock""" + HOTELMS = "hotelms" + """Hotel lock""" + HPS = "hps" + """Human presence sensor + + https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs + """ + JS = "js" + """Water purifier""" + JSQ = "jsq" + """Humidifier + + https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b + """ + JTMSBH = "jtmsbh" + """Smart lock (keep alive)""" + JTMSPRO = "jtmspro" + """Residential lock pro""" + JWBJ = "jwbj" + """Methane detector + + https://developer.tuya.com/en/docs/iot/categoryjwbj?id=Kaiuz40u98lkm + """ + KFJ = "kfj" + """Coffee maker + + https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f + """ + KG = "kg" + """Switch + + https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + """ + KJ = "kj" + """Air purifier + + https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm + """ + KQZG = "kqzg" + """Air fryer""" + KT = "kt" + """Air conditioner + + https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n + """ + KTKZQ = "ktkzq" + """Air conditioner controller""" + LDCG = "ldcg" + """Luminance sensor + + https://developer.tuya.com/en/docs/iot/categoryldcg?id=Kaiuz3n7u69l8 + """ + LILIAO = "liliao" + """Physiotherapy product""" + LYJ = "lyj" + """Drying rack""" + MAL = "mal" + """Alarm host + + https://developer.tuya.com/en/docs/iot/categorymal?id=Kaiuz33clqxaf + """ + MB = "mb" + """Bread maker""" + MC = "mc" + """Door/window controller + + https://developer.tuya.com/en/docs/iot/s?id=K9gf48r5zjsy9 + """ + MCS = "mcs" + """Contact sensor + + https://developer.tuya.com/en/docs/iot/s?id=K9gf48hm02l8m + """ + MG = "mg" + """Rice cabinet""" + MJJ = "mjj" + """Towel rack""" + MK = "mk" + """Access control + + https://developer.tuya.com/en/docs/iot/s?id=Kb0o2xhlkxbet + """ + MS = "ms" + """Residential lock""" + MS_CATEGORY = "ms_category" + """Lock accessories""" + MSP = "msp" + """Cat toilet""" + MZJ = "mzj" + """Sous vide cooker + + https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux + """ + NNQ = "nnq" + """Bottle warmer""" + NTQ = "ntq" + """HVAC""" + PC = "pc" + """Power strip + + https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s + """ + PHOTOLOCK = "photolock" + """Audio and video lock""" + PIR = "pir" + """Human motion sensor + + https://developer.tuya.com/en/docs/iot/categorypir?id=Kaiuz3ss11b80 + """ + PM2_5 = "pm2.5" + """PM2.5 detector + + https://developer.tuya.com/en/docs/iot/categorypm25?id=Kaiuz3qof3yfu + """ + QN = "qn" + """Heater + + https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm + """ + RQBJ = "rqbj" + """Gas alarm + + https://developer.tuya.com/en/docs/iot/categoryrqbj?id=Kaiuz3d162ubw + """ + RS = "rs" + """Water heater + + https://developer.tuya.com/en/docs/iot/categoryrs?id=Kaiuz0nfferyx + """ + SB = "sb" + """Watch/band""" + SD = "sd" + """Robot vacuum + + https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo + """ + SF = "sf" + """Sofa""" + SGBJ = "sgbj" + """Siren alarm + + https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu + """ + SJ = "sj" + """Water leak detector + + https://developer.tuya.com/en/docs/iot/categorysj?id=Kaiuz3iub2sli + """ + SOS = "sos" + """Emergency button + + https://developer.tuya.com/en/docs/iot/categorysos?id=Kaiuz3oi6agjy + """ + SP = "sp" + """Smart camera + + https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 + """ + SZ = "sz" + """Smart indoor garden + + https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 + """ + TGKG = "tgkg" + """Dimmer switch + + https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + """ + TGQ = "tgq" + """Dimmer + + https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o + """ + TNQ = "tnq" + """Smart milk kettle""" + TRACKER = "tracker" + """Tracker""" + TS = "ts" + """Smart jump rope""" + TYNDJ = "tyndj" + """Solar light + + https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 + """ + TYY = "tyy" + """Projector""" + TZC1 = "tzc1" + """Body fat scale""" + VIDEOLOCK = "videolock" + """Lock with camera""" + WK = "wk" + """Thermostat + + https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 + """ + WSDCG = "wsdcg" + """Temperature and humidity sensor + + https://developer.tuya.com/en/docs/iot/categorywsdcg?id=Kaiuz3hinij34 + """ + XDD = "xdd" + """Ceiling light + + https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r + """ + XFJ = "xfj" + """Ventilation system""" + XXJ = "xxj" + """Diffuser""" + XY = "xy" + """Washing machine""" + YB = "yb" + """Bathroom heater""" + YG = "yg" + """Bathtub""" + YKQ = "ykq" + """Remote control + + https://developer.tuya.com/en/docs/iot/ykq?id=Kaof8ljn81aov + """ + YLCG = "ylcg" + """Pressure sensor + + https://developer.tuya.com/en/docs/iot/categoryylcg?id=Kaiuz3kc2e4gm + """ + YWBJ = "ywbj" + """Smoke alarm + + https://developer.tuya.com/en/docs/iot/categoryywbj?id=Kaiuz3f6sf952 + """ + ZD = "zd" + """Vibration sensor + + https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno + """ + ZNDB = "zndb" + """Smart electricity meter""" + ZNFH = "znfh" + """Bento box""" + ZNSB = "znsb" + """Smart water meter""" + ZNYH = "znyh" + """Smart pill box""" + + # Undocumented + BZYD = "bzyd" + """White noise machine (undocumented)""" + CWJWQ = "cwjwq" + """Smart Odor Eliminator-Pro (undocumented) + + see https://github.com/orgs/home-assistant/discussions/79 + """ + DGHSXJ = "dghsxj" + """Smart Camera - Low power consumption camera (undocumented) + + see https://github.com/home-assistant/core/issues/132844 + """ + DSD = "dsd" + """Filament Light + + Based on data from https://github.com/home-assistant/core/issues/106703 + Product category mentioned in https://developer.tuya.com/en/docs/iot/oemapp-light?id=Kb77kja5woao6 + As at 30/12/23 not documented in https://developer.tuya.com/en/docs/iot/lighting?id=Kaiuyzxq30wmc + """ + FSKG = "fskg" + """Fan wall switch (undocumented)""" + HXD = "hxd" + """Wake Up Light II (undocumented)""" + JDCLJQR = "jdcljqr" + """Curtain Robot (undocumented)""" + JQBJ = "jqbj" + """Formaldehyde Detector (undocumented)""" + KS = "ks" + """Tower fan (undocumented) + + See https://github.com/orgs/home-assistant/discussions/329 + """ + MBD = "mbd" + """Unknown light product + + Found as VECINO RGBW as provided by diagnostics + """ + QJDCZ = "qjdcz" + """ Unknown product with light capabilities + + Found in some diffusers, plugs and PIR flood lights + """ + QXJ = "qxj" + """Temperature and Humidity Sensor with External Probe (undocumented) + + see https://github.com/home-assistant/core/issues/136472 + """ + SFKZQ = "sfkzq" + """Smart Water Timer (undocumented)""" + SJZ = "sjz" + """Electric desk (undocumented)""" + SZJQR = "szjqr" + """Fingerbot (undocumented)""" + SWTZ = "swtz" + """Cooking thermometer (undocumented)""" + TDQ = "tdq" + """Dimmer (undocumented)""" + TYD = "tyd" + """Outdoor flood light (undocumented)""" + VOC = "voc" + """Volatile Organic Compound Sensor (undocumented)""" + WG2 = "wg2" # Documented, but not in official list + """Gateway control + + https://developer.tuya.com/en/docs/iot/wg?id=Kbcdadk79ejok + """ + WKF = "wkf" + """Thermostatic Radiator Valve (undocumented)""" + WXKG = "wxkg" # Documented, but not in official list + """Wireless Switch + + https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp + """ + XNYJCN = "xnyjcn" + """Micro Storage Inverter + + Energy storage and solar PV inverter system with monitoring capabilities + """ + YWCGQ = "ywcgq" + """Tank Level Sensor (undocumented)""" + ZNRB = "znrb" + """Pool HeatPump""" + + class DPCode(StrEnum): """Data Point Codes used by Tuya. diff --git a/homeassistant/components/tuya/cover.py b/homeassistant/components/tuya/cover.py index 8b02d0adbda61b..3464b535c47448 100644 --- a/homeassistant/components/tuya/cover.py +++ b/homeassistant/components/tuya/cover.py @@ -158,17 +158,17 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya cover dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered tuya cover.""" entities: list[TuyaCoverEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := COVERS.get(device.category): entities.extend( - TuyaCoverEntity(device, hass_data.manager, description) + TuyaCoverEntity(device, manager, description) for description in descriptions if ( description.key in device.function @@ -178,7 +178,7 @@ def async_discover_device(device_ids: list[str]) -> None: async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/diagnostics.py b/homeassistant/components/tuya/diagnostics.py index 9675b215ce2083..b71a17f68a6c5f 100644 --- a/homeassistant/components/tuya/diagnostics.py +++ b/homeassistant/components/tuya/diagnostics.py @@ -39,15 +39,15 @@ def _async_get_diagnostics( device: DeviceEntry | None = None, ) -> dict[str, Any]: """Return diagnostics for a config entry.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager mqtt_connected = None - if hass_data.manager.mq.client: - mqtt_connected = hass_data.manager.mq.client.is_connected() + if manager.mq.client: + mqtt_connected = manager.mq.client.is_connected() data = { - "endpoint": hass_data.manager.customer_api.endpoint, - "terminal_id": hass_data.manager.terminal_id, + "endpoint": manager.customer_api.endpoint, + "terminal_id": manager.terminal_id, "mqtt_connected": mqtt_connected, "disabled_by": entry.disabled_by, "disabled_polling": entry.pref_disable_polling, @@ -55,14 +55,12 @@ def _async_get_diagnostics( if device: tuya_device_id = next(iter(device.identifiers))[1] - data |= _async_device_as_dict( - hass, hass_data.manager.device_map[tuya_device_id] - ) + data |= _async_device_as_dict(hass, manager.device_map[tuya_device_id]) else: data.update( devices=[ _async_device_as_dict(hass, device) - for device in hass_data.manager.device_map.values() + for device in manager.device_map.values() ] ) diff --git a/homeassistant/components/tuya/event.py b/homeassistant/components/tuya/event.py index 0c07844ffba208..4cfb22e4cce4f5 100644 --- a/homeassistant/components/tuya/event.py +++ b/homeassistant/components/tuya/event.py @@ -14,17 +14,14 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity # All descriptions can be found here. Mostly the Enum data types in the # default status set of each category (that don't have a set instruction) # end up being events. -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -EVENTS: dict[str, tuple[EventEntityDescription, ...]] = { - # Wireless Switch - # https://developer.tuya.com/en/docs/iot/s?id=Kbeoa9fkv6brp - "wxkg": ( +EVENTS: dict[DeviceCategory, tuple[EventEntityDescription, ...]] = { + DeviceCategory.WXKG: ( EventEntityDescription( key=DPCode.SWITCH_MODE1, device_class=EventDeviceClass.BUTTON, @@ -89,25 +86,23 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya events dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya binary sensor.""" entities: list[TuyaEventEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := EVENTS.get(device.category): for description in descriptions: dpcode = description.key if dpcode in device.status: - entities.append( - TuyaEventEntity(device, hass_data.manager, description) - ) + entities.append(TuyaEventEntity(device, manager, description)) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/fan.py b/homeassistant/components/tuya/fan.py index 12b6b11a297755..db16720ddc4232 100644 --- a/homeassistant/components/tuya/fan.py +++ b/homeassistant/components/tuya/fan.py @@ -21,7 +21,7 @@ ) from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData, IntegerTypeData from .util import get_dpcode @@ -36,24 +36,13 @@ ) _SWITCH_DPCODES = (DPCode.SWITCH_FAN, DPCode.FAN_SWITCH, DPCode.SWITCH) -TUYA_SUPPORT_TYPE = { - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha - "cs", - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs", - # Ceiling Fan Light - # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v - "fsd", - # Fan wall switch - "fskg", - # Air Purifier - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm - "kj", - # Undocumented tower fan - # https://github.com/orgs/home-assistant/discussions/329 - "ks", +TUYA_SUPPORT_TYPE: set[DeviceCategory] = { + DeviceCategory.CS, + DeviceCategory.FS, + DeviceCategory.FSD, + DeviceCategory.FSKG, + DeviceCategory.KJ, + DeviceCategory.KS, } @@ -76,19 +65,19 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya fan dynamically through tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered tuya fan.""" entities: list[TuyaFanEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if device.category in TUYA_SUPPORT_TYPE and _has_a_valid_dpcode(device): - entities.append(TuyaFanEntity(device, hass_data.manager)) + entities.append(TuyaFanEntity(device, manager)) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/humidifier.py b/homeassistant/components/tuya/humidifier.py index cb08ccaf476ed3..cc6fdd778fe802 100644 --- a/homeassistant/components/tuya/humidifier.py +++ b/homeassistant/components/tuya/humidifier.py @@ -18,7 +18,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity from .models import IntegerTypeData from .util import ActionDPCodeNotFoundError, get_dpcode @@ -49,19 +49,15 @@ def _has_a_valid_dpcode( return any(get_dpcode(device, code) for code in properties_to_check) -HUMIDIFIERS: dict[str, TuyaHumidifierEntityDescription] = { - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha - "cs": TuyaHumidifierEntityDescription( +HUMIDIFIERS: dict[DeviceCategory, TuyaHumidifierEntityDescription] = { + DeviceCategory.CS: TuyaHumidifierEntityDescription( key=DPCode.SWITCH, dpcode=(DPCode.SWITCH, DPCode.SWITCH_SPRAY), current_humidity=DPCode.HUMIDITY_INDOOR, humidity=DPCode.DEHUMIDITY_SET_VALUE, device_class=HumidifierDeviceClass.DEHUMIDIFIER, ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": TuyaHumidifierEntityDescription( + DeviceCategory.JSQ: TuyaHumidifierEntityDescription( key=DPCode.SWITCH, dpcode=(DPCode.SWITCH, DPCode.SWITCH_SPRAY), current_humidity=DPCode.HUMIDITY_CURRENT, @@ -77,23 +73,21 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya (de)humidifier dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya (de)humidifier.""" entities: list[TuyaHumidifierEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if ( description := HUMIDIFIERS.get(device.category) ) and _has_a_valid_dpcode(device, description): - entities.append( - TuyaHumidifierEntity(device, hass_data.manager, description) - ) + entities.append(TuyaHumidifierEntity(device, manager, description)) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/light.py b/homeassistant/components/tuya/light.py index 9dba24ec490b8f..d2cceaa46204de 100644 --- a/homeassistant/components/tuya/light.py +++ b/homeassistant/components/tuya/light.py @@ -26,7 +26,7 @@ from homeassistant.util import color as color_util from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode, DPType, WorkMode +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType, WorkMode from .entity import TuyaEntity from .models import IntegerTypeData from .util import get_dpcode, remap_value @@ -72,9 +72,8 @@ class TuyaLightEntityDescription(LightEntityDescription): ) -LIGHTS: dict[str, tuple[TuyaLightEntityDescription, ...]] = { - # White noise machine - "bzyd": ( +LIGHTS: dict[DeviceCategory, tuple[TuyaLightEntityDescription, ...]] = { + DeviceCategory.BZYD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -82,18 +81,14 @@ class TuyaLightEntityDescription(LightEntityDescription): color_data=DPCode.COLOUR_DATA, ), ), - # Curtain Switch - # https://developer.tuya.com/en/docs/iot/category-clkg?id=Kaiuz0gitil39 - "clkg": ( + DeviceCategory.CLKG: ( TuyaLightEntityDescription( key=DPCode.SWITCH_BACKLIGHT, translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), - # String Lights - # https://developer.tuya.com/en/docs/iot/dc?id=Kaof7taxmvadu - "dc": ( + DeviceCategory.DC: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -103,9 +98,7 @@ class TuyaLightEntityDescription(LightEntityDescription): color_data=DPCode.COLOUR_DATA, ), ), - # Strip Lights - # https://developer.tuya.com/en/docs/iot/dd?id=Kaof804aibg2l - "dd": ( + DeviceCategory.DD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -116,9 +109,7 @@ class TuyaLightEntityDescription(LightEntityDescription): default_color_type=DEFAULT_COLOR_TYPE_DATA_V2, ), ), - # Light - # https://developer.tuya.com/en/docs/iot/categorydj?id=Kaiuyzy3eheyy - "dj": ( + DeviceCategory.DJ: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -136,11 +127,7 @@ class TuyaLightEntityDescription(LightEntityDescription): brightness=DPCode.BRIGHT_VALUE_1, ), ), - # Filament Light - # Based on data from https://github.com/home-assistant/core/issues/106703 - # Product category mentioned in https://developer.tuya.com/en/docs/iot/oemapp-light?id=Kb77kja5woao6 - # As at 30/12/23 not documented in https://developer.tuya.com/en/docs/iot/lighting?id=Kaiuyzxq30wmc - "dsd": ( + DeviceCategory.DSD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -148,9 +135,7 @@ class TuyaLightEntityDescription(LightEntityDescription): brightness=DPCode.BRIGHT_VALUE, ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs": ( + DeviceCategory.FS: ( TuyaLightEntityDescription( key=DPCode.LIGHT, name=None, @@ -165,9 +150,7 @@ class TuyaLightEntityDescription(LightEntityDescription): brightness=DPCode.BRIGHT_VALUE_1, ), ), - # Ceiling Fan Light - # https://developer.tuya.com/en/docs/iot/fsd?id=Kaof8eiei4c2v - "fsd": ( + DeviceCategory.FSD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -182,9 +165,7 @@ class TuyaLightEntityDescription(LightEntityDescription): name=None, ), ), - # Ambient Light - # https://developer.tuya.com/en/docs/iot/ambient-light?id=Kaiuz06amhe6g - "fwd": ( + DeviceCategory.FWD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -194,9 +175,7 @@ class TuyaLightEntityDescription(LightEntityDescription): color_data=DPCode.COLOUR_DATA, ), ), - # Motion Sensor Light - # https://developer.tuya.com/en/docs/iot/gyd?id=Kaof8a8hycfmy - "gyd": ( + DeviceCategory.GYD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -206,9 +185,7 @@ class TuyaLightEntityDescription(LightEntityDescription): color_data=DPCode.COLOUR_DATA, ), ), - # Wake Up Light II - # Not documented - "hxd": ( + DeviceCategory.HXD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, translation_key="light", @@ -217,9 +194,7 @@ class TuyaLightEntityDescription(LightEntityDescription): brightness_min=DPCode.BRIGHTNESS_MIN_1, ), ), - # Humidifier Light - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( + DeviceCategory.JSQ: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -228,46 +203,35 @@ class TuyaLightEntityDescription(LightEntityDescription): color_data=DPCode.COLOUR_DATA_HSV, ), ), - # Switch - # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s - "kg": ( + DeviceCategory.KG: ( TuyaLightEntityDescription( key=DPCode.SWITCH_BACKLIGHT, translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), - # Air Purifier - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm - "kj": ( + DeviceCategory.KJ: ( TuyaLightEntityDescription( key=DPCode.LIGHT, translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), - # Air conditioner - # https://developer.tuya.com/en/docs/iot/categorykt?id=Kaiuz0z71ov2n - "kt": ( + DeviceCategory.KT: ( TuyaLightEntityDescription( key=DPCode.LIGHT, translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), - # Undocumented tower fan - # https://github.com/orgs/home-assistant/discussions/329 - "ks": ( + DeviceCategory.KS: ( TuyaLightEntityDescription( key=DPCode.LIGHT, translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), - # Unknown light product - # Found as VECINO RGBW as provided by diagnostics - # Not documented - "mbd": ( + DeviceCategory.MBD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -276,10 +240,7 @@ class TuyaLightEntityDescription(LightEntityDescription): color_data=DPCode.COLOUR_DATA, ), ), - # Unknown product with light capabilities - # Fond in some diffusers, plugs and PIR flood lights - # Not documented - "qjdcz": ( + DeviceCategory.QJDCZ: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -288,18 +249,14 @@ class TuyaLightEntityDescription(LightEntityDescription): color_data=DPCode.COLOUR_DATA, ), ), - # Heater - # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm - "qn": ( + DeviceCategory.QN: ( TuyaLightEntityDescription( key=DPCode.LIGHT, translation_key="backlight", entity_category=EntityCategory.CONFIG, ), ), - # Smart Camera - # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 - "sp": ( + DeviceCategory.SP: ( TuyaLightEntityDescription( key=DPCode.FLOODLIGHT_SWITCH, brightness=DPCode.FLOODLIGHT_LIGHTNESS, @@ -311,18 +268,14 @@ class TuyaLightEntityDescription(LightEntityDescription): entity_category=EntityCategory.CONFIG, ), ), - # Smart Gardening system - # https://developer.tuya.com/en/docs/iot/categorysz?id=Kaiuz4e6h7up0 - "sz": ( + DeviceCategory.SZ: ( TuyaLightEntityDescription( key=DPCode.LIGHT, brightness=DPCode.BRIGHT_VALUE, translation_key="light", ), ), - # Dimmer Switch - # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o - "tgkg": ( + DeviceCategory.TGKG: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED_1, translation_key="indexed_light", @@ -348,9 +301,7 @@ class TuyaLightEntityDescription(LightEntityDescription): brightness_min=DPCode.BRIGHTNESS_MIN_3, ), ), - # Dimmer - # https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4 - "tgq": ( + DeviceCategory.TGQ: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, translation_key="light", @@ -371,9 +322,7 @@ class TuyaLightEntityDescription(LightEntityDescription): brightness=DPCode.BRIGHT_VALUE_2, ), ), - # Outdoor Flood Light - # Not documented - "tyd": ( + DeviceCategory.TYD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -383,9 +332,7 @@ class TuyaLightEntityDescription(LightEntityDescription): color_data=DPCode.COLOUR_DATA, ), ), - # Solar Light - # https://developer.tuya.com/en/docs/iot/tynd?id=Kaof8j02e1t98 - "tyndj": ( + DeviceCategory.TYNDJ: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -395,9 +342,7 @@ class TuyaLightEntityDescription(LightEntityDescription): color_data=DPCode.COLOUR_DATA, ), ), - # Ceiling Light - # https://developer.tuya.com/en/docs/iot/ceiling-light?id=Kaiuz03xxfc4r - "xdd": ( + DeviceCategory.XDD: ( TuyaLightEntityDescription( key=DPCode.SWITCH_LED, name=None, @@ -411,9 +356,7 @@ class TuyaLightEntityDescription(LightEntityDescription): translation_key="night_light", ), ), - # Remote Control - # https://developer.tuya.com/en/docs/iot/ykq?id=Kaof8ljn81aov - "ykq": ( + DeviceCategory.YKQ: ( TuyaLightEntityDescription( key=DPCode.SWITCH_CONTROLLER, name=None, @@ -426,19 +369,16 @@ class TuyaLightEntityDescription(LightEntityDescription): # Socket (duplicate of `kg`) # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -LIGHTS["cz"] = LIGHTS["kg"] +LIGHTS[DeviceCategory.CZ] = LIGHTS[DeviceCategory.KG] # Power Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -LIGHTS["pc"] = LIGHTS["kg"] +LIGHTS[DeviceCategory.PC] = LIGHTS[DeviceCategory.KG] # Smart Camera - Low power consumption camera (duplicate of `sp`) -# Undocumented, see https://github.com/home-assistant/core/issues/132844 -LIGHTS["dghsxj"] = LIGHTS["sp"] +LIGHTS[DeviceCategory.DGHSXJ] = LIGHTS[DeviceCategory.SP] # Dimmer (duplicate of `tgq`) -# https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4 -LIGHTS["tdq"] = LIGHTS["tgq"] +LIGHTS[DeviceCategory.TDQ] = LIGHTS[DeviceCategory.TGQ] @dataclass @@ -470,24 +410,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya light dynamically through tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]): """Discover and add a discovered tuya light.""" entities: list[TuyaLightEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := LIGHTS.get(device.category): entities.extend( - TuyaLightEntity(device, hass_data.manager, description) + TuyaLightEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/number.py b/homeassistant/components/tuya/number.py index 6a4482821badaf..1fb00a4de5144e 100644 --- a/homeassistant/components/tuya/number.py +++ b/homeassistant/components/tuya/number.py @@ -21,6 +21,7 @@ DOMAIN, LOGGER, TUYA_DISCOVERY_NEW, + DeviceCategory, DPCode, DPType, ) @@ -28,13 +29,8 @@ from .models import IntegerTypeData from .util import ActionDPCodeNotFoundError -# All descriptions can be found here. Mostly the Integer data types in the -# default instructions set of each category end up being a number. -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -NUMBERS: dict[str, tuple[NumberEntityDescription, ...]] = { - # Smart Kettle - # https://developer.tuya.com/en/docs/iot/fbh?id=K9gf484m21yq7 - "bh": ( +NUMBERS: dict[DeviceCategory, tuple[NumberEntityDescription, ...]] = { + DeviceCategory.BH: ( NumberEntityDescription( key=DPCode.TEMP_SET, translation_key="temperature", @@ -65,17 +61,14 @@ entity_category=EntityCategory.CONFIG, ), ), - # White noise machine - "bzyd": ( + DeviceCategory.BZYD: ( NumberEntityDescription( key=DPCode.VOLUME_SET, translation_key="volume", entity_category=EntityCategory.CONFIG, ), ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( + DeviceCategory.CO2BJ: ( NumberEntityDescription( key=DPCode.ALARM_TIME, translation_key="alarm_duration", @@ -84,9 +77,7 @@ entity_category=EntityCategory.CONFIG, ), ), - # Smart Pet Feeder - # https://developer.tuya.com/en/docs/iot/categorycwwsq?id=Kaiuz2b6vydld - "cwwsq": ( + DeviceCategory.CWWSQ: ( NumberEntityDescription( key=DPCode.MANUAL_FEED, translation_key="feed", @@ -96,27 +87,21 @@ translation_key="voice_times", ), ), - # Multi-functional Sensor - # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 - "dgnbj": ( + DeviceCategory.DGNBJ: ( NumberEntityDescription( key=DPCode.ALARM_TIME, translation_key="time", entity_category=EntityCategory.CONFIG, ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/categoryfs?id=Kaiuz1xweel1c - "fs": ( + DeviceCategory.FS: ( NumberEntityDescription( key=DPCode.TEMP, translation_key="temperature", device_class=NumberDeviceClass.TEMPERATURE, ), ), - # Human Presence Sensor - # https://developer.tuya.com/en/docs/iot/categoryhps?id=Kaiuz42yhn1hs - "hps": ( + DeviceCategory.HPS: ( NumberEntityDescription( key=DPCode.SENSITIVITY, translation_key="sensitivity", @@ -140,9 +125,7 @@ device_class=NumberDeviceClass.DISTANCE, ), ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( + DeviceCategory.JSQ: ( NumberEntityDescription( key=DPCode.TEMP_SET, translation_key="temperature", @@ -154,9 +137,7 @@ device_class=NumberDeviceClass.TEMPERATURE, ), ), - # Coffee maker - # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f - "kfj": ( + DeviceCategory.KFJ: ( NumberEntityDescription( key=DPCode.WATER_SET, translation_key="water_level", @@ -179,9 +160,7 @@ entity_category=EntityCategory.CONFIG, ), ), - # Alarm Host - # https://developer.tuya.com/en/docs/iot/alarm-hosts?id=K9gf48r87hyjk - "mal": ( + DeviceCategory.MAL: ( NumberEntityDescription( key=DPCode.DELAY_SET, # This setting is called "Arm Delay" in the official Tuya app @@ -203,9 +182,7 @@ entity_category=EntityCategory.CONFIG, ), ), - # Sous Vide Cooker - # https://developer.tuya.com/en/docs/iot/categorymzj?id=Kaiuz2vy130ux - "mzj": ( + DeviceCategory.MZJ: ( NumberEntityDescription( key=DPCode.COOK_TEMPERATURE, translation_key="cook_temperature", @@ -223,8 +200,7 @@ entity_category=EntityCategory.CONFIG, ), ), - # Cooking thermometer - "swtz": ( + DeviceCategory.SWTZ: ( NumberEntityDescription( key=DPCode.COOK_TEMPERATURE, translation_key="cook_temperature", @@ -237,17 +213,14 @@ entity_category=EntityCategory.CONFIG, ), ), - # Robot Vacuum - # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo - "sd": ( + DeviceCategory.SD: ( NumberEntityDescription( key=DPCode.VOLUME_SET, translation_key="volume", entity_category=EntityCategory.CONFIG, ), ), - # Smart Water Timer - "sfkzq": ( + DeviceCategory.SFKZQ: ( # Controls the irrigation duration for the water valve NumberEntityDescription( key=DPCode.COUNTDOWN_1, @@ -306,26 +279,21 @@ entity_category=EntityCategory.CONFIG, ), ), - # Siren Alarm - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sgbj": ( + DeviceCategory.SGBJ: ( NumberEntityDescription( key=DPCode.ALARM_TIME, translation_key="time", entity_category=EntityCategory.CONFIG, ), ), - # Smart Camera - # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 - "sp": ( + DeviceCategory.SP: ( NumberEntityDescription( key=DPCode.BASIC_DEVICE_VOLUME, translation_key="volume", entity_category=EntityCategory.CONFIG, ), ), - # Fingerbot - "szjqr": ( + DeviceCategory.SZJQR: ( NumberEntityDescription( key=DPCode.ARM_DOWN_PERCENT, translation_key="move_down", @@ -344,9 +312,7 @@ entity_category=EntityCategory.CONFIG, ), ), - # Dimmer Switch - # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o - "tgkg": ( + DeviceCategory.TGKG: ( NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_1, translation_key="indexed_minimum_brightness", @@ -384,9 +350,7 @@ entity_category=EntityCategory.CONFIG, ), ), - # Dimmer Switch - # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o - "tgq": ( + DeviceCategory.TGQ: ( NumberEntityDescription( key=DPCode.BRIGHTNESS_MIN_1, translation_key="indexed_minimum_brightness", @@ -412,18 +376,14 @@ entity_category=EntityCategory.CONFIG, ), ), - # Thermostat - # https://developer.tuya.com/en/docs/iot/f?id=K9gf45ld5l0t9 - "wk": ( + DeviceCategory.WK: ( NumberEntityDescription( key=DPCode.TEMP_CORRECTION, translation_key="temp_correction", entity_category=EntityCategory.CONFIG, ), ), - # Micro Storage Inverter - # Energy storage and solar PV inverter system with monitoring capabilities - "xnyjcn": ( + DeviceCategory.XNYJCN: ( NumberEntityDescription( key=DPCode.BACKUP_RESERVE, translation_key="battery_backup_reserve", @@ -436,9 +396,7 @@ entity_category=EntityCategory.CONFIG, ), ), - # Tank Level Sensor - # Note: Undocumented - "ywcgq": ( + DeviceCategory.YWCGQ: ( NumberEntityDescription( key=DPCode.MAX_SET, translation_key="alarm_maximum", @@ -462,17 +420,14 @@ entity_category=EntityCategory.CONFIG, ), ), - # Vibration Sensor - # https://developer.tuya.com/en/docs/iot/categoryzd?id=Kaiuz3a5vrzno - "zd": ( + DeviceCategory.ZD: ( NumberEntityDescription( key=DPCode.SENSITIVITY, translation_key="sensitivity", entity_category=EntityCategory.CONFIG, ), ), - # Pool HeatPump - "znrb": ( + DeviceCategory.ZNRB: ( NumberEntityDescription( key=DPCode.TEMP_SET, translation_key="temperature", @@ -482,8 +437,7 @@ } # Smart Camera - Low power consumption camera (duplicate of `sp`) -# Undocumented, see https://github.com/home-assistant/core/issues/132844 -NUMBERS["dghsxj"] = NUMBERS["sp"] +NUMBERS[DeviceCategory.DGHSXJ] = NUMBERS[DeviceCategory.SP] async def async_setup_entry( @@ -492,24 +446,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya number dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya number.""" entities: list[TuyaNumberEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := NUMBERS.get(device.category): entities.extend( - TuyaNumberEntity(device, hass_data.manager, description) + TuyaNumberEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/scene.py b/homeassistant/components/tuya/scene.py index 4ad027d39eed60..239aabd9bccc9a 100644 --- a/homeassistant/components/tuya/scene.py +++ b/homeassistant/components/tuya/scene.py @@ -21,9 +21,9 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya scenes.""" - hass_data = entry.runtime_data - scenes = await hass.async_add_executor_job(hass_data.manager.query_scenes) - async_add_entities(TuyaSceneEntity(hass_data.manager, scene) for scene in scenes) + manager = entry.runtime_data.manager + scenes = await hass.async_add_executor_job(manager.query_scenes) + async_add_entities(TuyaSceneEntity(manager, scene) for scene in scenes) class TuyaSceneEntity(Scene): diff --git a/homeassistant/components/tuya/select.py b/homeassistant/components/tuya/select.py index 0d62620b88e525..6a4d8d7b488348 100644 --- a/homeassistant/components/tuya/select.py +++ b/homeassistant/components/tuya/select.py @@ -11,16 +11,13 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity # All descriptions can be found here. Mostly the Enum data types in the # default instructions set of each category end up being a select. -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -SELECTS: dict[str, tuple[SelectEntityDescription, ...]] = { - # Curtain - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46o5mtfyc - "cl": ( +SELECTS: dict[DeviceCategory, tuple[SelectEntityDescription, ...]] = { + DeviceCategory.CL: ( SelectEntityDescription( key=DPCode.CONTROL_BACK_MODE, entity_category=EntityCategory.CONFIG, @@ -32,18 +29,14 @@ translation_key="curtain_mode", ), ), - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( + DeviceCategory.CO2BJ: ( SelectEntityDescription( key=DPCode.ALARM_VOLUME, translation_key="volume", entity_category=EntityCategory.CONFIG, ), ), - # Dehumidifier - # https://developer.tuya.com/en/docs/iot/categorycs?id=Kaiuz1vcz4dha - "cs": ( + DeviceCategory.CS: ( SelectEntityDescription( key=DPCode.COUNTDOWN_SET, entity_category=EntityCategory.CONFIG, @@ -55,27 +48,21 @@ entity_category=EntityCategory.CONFIG, ), ), - # Smart Odor Eliminator-Pro - # Undocumented, see https://github.com/orgs/home-assistant/discussions/79 - "cwjwq": ( + DeviceCategory.CWJWQ: ( SelectEntityDescription( key=DPCode.WORK_MODE, entity_category=EntityCategory.CONFIG, translation_key="odor_elimination_mode", ), ), - # Multi-functional Sensor - # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 - "dgnbj": ( + DeviceCategory.DGNBJ: ( SelectEntityDescription( key=DPCode.ALARM_VOLUME, translation_key="volume", entity_category=EntityCategory.CONFIG, ), ), - # Electric Blanket - # https://developer.tuya.com/en/docs/iot/categorydr?id=Kaiuz22dyc66p - "dr": ( + DeviceCategory.DR: ( SelectEntityDescription( key=DPCode.LEVEL, icon="mdi:thermometer-lines", @@ -94,9 +81,7 @@ translation_placeholders={"index": "2"}, ), ), - # Fan - # https://developer.tuya.com/en/docs/iot/f?id=K9gf45vs7vkge - "fs": ( + DeviceCategory.FS: ( SelectEntityDescription( key=DPCode.FAN_VERTICAL, entity_category=EntityCategory.CONFIG, @@ -118,9 +103,7 @@ translation_key="countdown", ), ), - # Humidifier - # https://developer.tuya.com/en/docs/iot/categoryjsq?id=Kaiuz1smr440b - "jsq": ( + DeviceCategory.JSQ: ( SelectEntityDescription( key=DPCode.SPRAY_MODE, entity_category=EntityCategory.CONFIG, @@ -147,9 +130,7 @@ translation_key="countdown", ), ), - # Coffee maker - # https://developer.tuya.com/en/docs/iot/categorykfj?id=Kaiuz2p12pc7f - "kfj": ( + DeviceCategory.KFJ: ( SelectEntityDescription( key=DPCode.CUP_NUMBER, translation_key="cups", @@ -169,9 +150,7 @@ translation_key="mode", ), ), - # Switch - # https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s - "kg": ( + DeviceCategory.KG: ( SelectEntityDescription( key=DPCode.RELAY_STATUS, entity_category=EntityCategory.CONFIG, @@ -183,9 +162,7 @@ translation_key="light_mode", ), ), - # Air Purifier - # https://developer.tuya.com/en/docs/iot/f?id=K9gf46h2s6dzm - "kj": ( + DeviceCategory.KJ: ( SelectEntityDescription( key=DPCode.COUNTDOWN, entity_category=EntityCategory.CONFIG, @@ -197,17 +174,13 @@ translation_key="countdown", ), ), - # Heater - # https://developer.tuya.com/en/docs/iot/categoryqn?id=Kaiuz18kih0sm - "qn": ( + DeviceCategory.QN: ( SelectEntityDescription( key=DPCode.LEVEL, translation_key="temperature_level", ), ), - # Robot Vacuum - # https://developer.tuya.com/en/docs/iot/fsd?id=K9gf487ck1tlo - "sd": ( + DeviceCategory.SD: ( SelectEntityDescription( key=DPCode.CISTERN, entity_category=EntityCategory.CONFIG, @@ -224,8 +197,7 @@ translation_key="vacuum_mode", ), ), - # Smart Water Timer - "sfkzq": ( + DeviceCategory.SFKZQ: ( # Irrigation will not be run within this set delay period SelectEntityDescription( key=DPCode.WEATHER_DELAY, @@ -233,9 +205,7 @@ entity_category=EntityCategory.CONFIG, ), ), - # Siren Alarm - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sgbj": ( + DeviceCategory.SGBJ: ( SelectEntityDescription( key=DPCode.ALARM_VOLUME, translation_key="volume", @@ -247,8 +217,7 @@ entity_category=EntityCategory.CONFIG, ), ), - # Electric desk - "sjz": ( + DeviceCategory.SJZ: ( SelectEntityDescription( key=DPCode.LEVEL, translation_key="desk_level", @@ -260,9 +229,7 @@ entity_category=EntityCategory.CONFIG, ), ), - # Smart Camera - # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 - "sp": ( + DeviceCategory.SP: ( SelectEntityDescription( key=DPCode.IPC_WORK_MODE, entity_category=EntityCategory.CONFIG, @@ -294,17 +261,14 @@ translation_key="motion_sensitivity", ), ), - # Fingerbot - "szjqr": ( + DeviceCategory.SZJQR: ( SelectEntityDescription( key=DPCode.MODE, entity_category=EntityCategory.CONFIG, translation_key="fingerbot_mode", ), ), - # IoT Switch? - # Note: Undocumented - "tdq": ( + DeviceCategory.TDQ: ( SelectEntityDescription( key=DPCode.RELAY_STATUS, entity_category=EntityCategory.CONFIG, @@ -316,9 +280,7 @@ translation_key="light_mode", ), ), - # Dimmer Switch - # https://developer.tuya.com/en/docs/iot/categorytgkg?id=Kaiuz0ktx7m0o - "tgkg": ( + DeviceCategory.TGKG: ( SelectEntityDescription( key=DPCode.RELAY_STATUS, entity_category=EntityCategory.CONFIG, @@ -348,9 +310,7 @@ translation_placeholders={"index": "3"}, ), ), - # Dimmer - # https://developer.tuya.com/en/docs/iot/tgq?id=Kaof8ke9il4k4 - "tgq": ( + DeviceCategory.TGQ: ( SelectEntityDescription( key=DPCode.LED_TYPE_1, entity_category=EntityCategory.CONFIG, @@ -364,9 +324,7 @@ translation_placeholders={"index": "2"}, ), ), - # Micro Storage Inverter - # Energy storage and solar PV inverter system with monitoring capabilities - "xnyjcn": ( + DeviceCategory.XNYJCN: ( SelectEntityDescription( key=DPCode.WORK_MODE, translation_key="inverter_work_mode", @@ -376,16 +334,13 @@ } # Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SELECTS["cz"] = SELECTS["kg"] +SELECTS[DeviceCategory.CZ] = SELECTS[DeviceCategory.KG] # Smart Camera - Low power consumption camera (duplicate of `sp`) -# Undocumented, see https://github.com/home-assistant/core/issues/132844 -SELECTS["dghsxj"] = SELECTS["sp"] +SELECTS[DeviceCategory.DGHSXJ] = SELECTS[DeviceCategory.SP] # Power Socket (duplicate of `kg`) -# https://developer.tuya.com/en/docs/iot/s?id=K9gf7o5prgf7s -SELECTS["pc"] = SELECTS["kg"] +SELECTS[DeviceCategory.PC] = SELECTS[DeviceCategory.KG] async def async_setup_entry( @@ -394,24 +349,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya select dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya select.""" entities: list[TuyaSelectEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := SELECTS.get(device.category): entities.extend( - TuyaSelectEntity(device, hass_data.manager, description) + TuyaSelectEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/sensor.py b/homeassistant/components/tuya/sensor.py index 0c2c1e8f9247bf..3851287ce46683 100644 --- a/homeassistant/components/tuya/sensor.py +++ b/homeassistant/components/tuya/sensor.py @@ -1717,24 +1717,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya sensor dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya sensor.""" entities: list[TuyaSensorEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := SENSORS.get(device.category): entities.extend( - TuyaSensorEntity(device, hass_data.manager, description) + TuyaSensorEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/siren.py b/homeassistant/components/tuya/siren.py index 8003dc2cf212ea..8c29684ba9f130 100644 --- a/homeassistant/components/tuya/siren.py +++ b/homeassistant/components/tuya/siren.py @@ -17,37 +17,27 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -# All descriptions can be found here: -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -SIRENS: dict[str, tuple[SirenEntityDescription, ...]] = { - # CO2 Detector - # https://developer.tuya.com/en/docs/iot/categoryco2bj?id=Kaiuz3wes7yuy - "co2bj": ( +SIRENS: dict[DeviceCategory, tuple[SirenEntityDescription, ...]] = { + DeviceCategory.CO2BJ: ( SirenEntityDescription( key=DPCode.ALARM_SWITCH, entity_category=EntityCategory.CONFIG, ), ), - # Multi-functional Sensor - # https://developer.tuya.com/en/docs/iot/categorydgnbj?id=Kaiuz3yorvzg3 - "dgnbj": ( + DeviceCategory.DGNBJ: ( SirenEntityDescription( key=DPCode.ALARM_SWITCH, ), ), - # Siren Alarm - # https://developer.tuya.com/en/docs/iot/categorysgbj?id=Kaiuz37tlpbnu - "sgbj": ( + DeviceCategory.SGBJ: ( SirenEntityDescription( key=DPCode.ALARM_SWITCH, ), ), - # Smart Camera - # https://developer.tuya.com/en/docs/iot/categorysp?id=Kaiuz35leyo12 - "sp": ( + DeviceCategory.SP: ( SirenEntityDescription( key=DPCode.SIREN_SWITCH, ), @@ -55,8 +45,7 @@ } # Smart Camera - Low power consumption camera (duplicate of `sp`) -# Undocumented, see https://github.com/home-assistant/core/issues/132844 -SIRENS["dghsxj"] = SIRENS["sp"] +SIRENS[DeviceCategory.DGHSXJ] = SIRENS[DeviceCategory.SP] async def async_setup_entry( @@ -65,24 +54,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya siren dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya siren.""" entities: list[TuyaSirenEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := SIRENS.get(device.category): entities.extend( - TuyaSirenEntity(device, hass_data.manager, description) + TuyaSirenEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/switch.py b/homeassistant/components/tuya/switch.py index f5324888d818ad..d34123e0271165 100644 --- a/homeassistant/components/tuya/switch.py +++ b/homeassistant/components/tuya/switch.py @@ -999,7 +999,7 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya sensors dynamically through tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager entity_registry = er.async_get(hass) @callback @@ -1007,10 +1007,10 @@ def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered tuya sensor.""" entities: list[TuyaSwitchEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := SWITCHES.get(device.category): entities.extend( - TuyaSwitchEntity(device, hass_data.manager, description) + TuyaSwitchEntity(device, manager, description) for description in descriptions if description.key in device.status and _check_deprecation( @@ -1023,7 +1023,7 @@ def async_discover_device(device_ids: list[str]) -> None: async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/vacuum.py b/homeassistant/components/tuya/vacuum.py index c32d773c7921f8..8e0674ad23a946 100644 --- a/homeassistant/components/tuya/vacuum.py +++ b/homeassistant/components/tuya/vacuum.py @@ -16,7 +16,7 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode, DPType +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode, DPType from .entity import TuyaEntity from .models import EnumTypeData from .util import get_dpcode @@ -55,19 +55,19 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up Tuya vacuum dynamically through Tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered Tuya vacuum.""" entities: list[TuyaVacuumEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] - if device.category == "sd": - entities.append(TuyaVacuumEntity(device, hass_data.manager)) + device = manager.device_map[device_id] + if device.category == DeviceCategory.SD: + entities.append(TuyaVacuumEntity(device, manager)) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/tuya/valve.py b/homeassistant/components/tuya/valve.py index 42d4556a0d02d4..f14d605c19a1a9 100644 --- a/homeassistant/components/tuya/valve.py +++ b/homeassistant/components/tuya/valve.py @@ -15,15 +15,11 @@ from homeassistant.helpers.entity_platform import AddConfigEntryEntitiesCallback from . import TuyaConfigEntry -from .const import TUYA_DISCOVERY_NEW, DPCode +from .const import TUYA_DISCOVERY_NEW, DeviceCategory, DPCode from .entity import TuyaEntity -# All descriptions can be found here. Mostly the Boolean data types in the -# default instruction set of each category end up being a Valve. -# https://developer.tuya.com/en/docs/iot/standarddescription?id=K9i5ql6waswzq -VALVES: dict[str, tuple[ValveEntityDescription, ...]] = { - # Smart Water Timer - "sfkzq": ( +VALVES: dict[DeviceCategory, tuple[ValveEntityDescription, ...]] = { + DeviceCategory.SFKZQ: ( ValveEntityDescription( key=DPCode.SWITCH, translation_key="valve", @@ -87,24 +83,24 @@ async def async_setup_entry( async_add_entities: AddConfigEntryEntitiesCallback, ) -> None: """Set up tuya valves dynamically through tuya discovery.""" - hass_data = entry.runtime_data + manager = entry.runtime_data.manager @callback def async_discover_device(device_ids: list[str]) -> None: """Discover and add a discovered tuya valve.""" entities: list[TuyaValveEntity] = [] for device_id in device_ids: - device = hass_data.manager.device_map[device_id] + device = manager.device_map[device_id] if descriptions := VALVES.get(device.category): entities.extend( - TuyaValveEntity(device, hass_data.manager, description) + TuyaValveEntity(device, manager, description) for description in descriptions if description.key in device.status ) async_add_entities(entities) - async_discover_device([*hass_data.manager.device_map]) + async_discover_device([*manager.device_map]) entry.async_on_unload( async_dispatcher_connect(hass, TUYA_DISCOVERY_NEW, async_discover_device) diff --git a/homeassistant/components/uptimerobot/binary_sensor.py b/homeassistant/components/uptimerobot/binary_sensor.py index e8803b6ad89585..52e490222fc7e0 100644 --- a/homeassistant/components/uptimerobot/binary_sensor.py +++ b/homeassistant/components/uptimerobot/binary_sensor.py @@ -24,17 +24,29 @@ async def async_setup_entry( ) -> None: """Set up the UptimeRobot binary_sensors.""" coordinator = entry.runtime_data - async_add_entities( - UptimeRobotBinarySensor( - coordinator, - BinarySensorEntityDescription( - key=str(monitor.id), - device_class=BinarySensorDeviceClass.CONNECTIVITY, - ), - monitor=monitor, - ) - for monitor in coordinator.data - ) + + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = {monitor.id for monitor in coordinator.data} + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + UptimeRobotBinarySensor( + coordinator, + BinarySensorEntityDescription( + key=str(monitor.id), + device_class=BinarySensorDeviceClass.CONNECTIVITY, + ), + monitor=monitor, + ) + for monitor in coordinator.data + if monitor.id in new_devices + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class UptimeRobotBinarySensor(UptimeRobotEntity, BinarySensorEntity): diff --git a/homeassistant/components/uptimerobot/quality_scale.yaml b/homeassistant/components/uptimerobot/quality_scale.yaml index 01da4dc5166cb8..de85152315a265 100644 --- a/homeassistant/components/uptimerobot/quality_scale.yaml +++ b/homeassistant/components/uptimerobot/quality_scale.yaml @@ -57,9 +57,7 @@ rules: docs-supported-functions: done docs-troubleshooting: done docs-use-cases: done - dynamic-devices: - status: todo - comment: create entities on runtime instead of triggering a reload + dynamic-devices: done entity-category: done entity-device-class: done entity-disabled-by-default: diff --git a/homeassistant/components/uptimerobot/sensor.py b/homeassistant/components/uptimerobot/sensor.py index 3ed97d175081f1..7a241d6999be9f 100644 --- a/homeassistant/components/uptimerobot/sensor.py +++ b/homeassistant/components/uptimerobot/sensor.py @@ -33,20 +33,38 @@ async def async_setup_entry( ) -> None: """Set up the UptimeRobot sensors.""" coordinator = entry.runtime_data - async_add_entities( - UptimeRobotSensor( - coordinator, - SensorEntityDescription( - key=str(monitor.id), - entity_category=EntityCategory.DIAGNOSTIC, - device_class=SensorDeviceClass.ENUM, - options=["down", "not_checked_yet", "pause", "seems_down", "up"], - translation_key="monitor_status", - ), - monitor=monitor, - ) - for monitor in coordinator.data - ) + + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = {monitor.id for monitor in coordinator.data} + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + UptimeRobotSensor( + coordinator, + SensorEntityDescription( + key=str(monitor.id), + entity_category=EntityCategory.DIAGNOSTIC, + device_class=SensorDeviceClass.ENUM, + options=[ + "down", + "not_checked_yet", + "pause", + "seems_down", + "up", + ], + translation_key="monitor_status", + ), + monitor=monitor, + ) + for monitor in coordinator.data + if monitor.id in new_devices + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class UptimeRobotSensor(UptimeRobotEntity, SensorEntity): diff --git a/homeassistant/components/uptimerobot/switch.py b/homeassistant/components/uptimerobot/switch.py index 5d80903ed02080..531131034ce0bc 100644 --- a/homeassistant/components/uptimerobot/switch.py +++ b/homeassistant/components/uptimerobot/switch.py @@ -30,17 +30,29 @@ async def async_setup_entry( ) -> None: """Set up the UptimeRobot switches.""" coordinator = entry.runtime_data - async_add_entities( - UptimeRobotSwitch( - coordinator, - SwitchEntityDescription( - key=str(monitor.id), - device_class=SwitchDeviceClass.SWITCH, - ), - monitor=monitor, - ) - for monitor in coordinator.data - ) + + known_devices: set[int] = set() + + def _check_device() -> None: + current_devices = {monitor.id for monitor in coordinator.data} + new_devices = current_devices - known_devices + if new_devices: + known_devices.update(new_devices) + async_add_entities( + UptimeRobotSwitch( + coordinator, + SwitchEntityDescription( + key=str(monitor.id), + device_class=SwitchDeviceClass.SWITCH, + ), + monitor=monitor, + ) + for monitor in coordinator.data + if monitor.id in new_devices + ) + + _check_device() + entry.async_on_unload(coordinator.async_add_listener(_check_device)) class UptimeRobotSwitch(UptimeRobotEntity, SwitchEntity): diff --git a/homeassistant/const.py b/homeassistant/const.py index fdea434b8cb7bb..3b9702b972ee6b 100644 --- a/homeassistant/const.py +++ b/homeassistant/const.py @@ -37,7 +37,7 @@ # Format for platform files PLATFORM_FORMAT: Final = "{platform}.{domain}" -# Type alias to avoid 1000 MyPy errors +# Explicit reexport to allow other modules to import Platform directly from const Platform = EntityPlatforms BASE_PLATFORMS: Final = {platform.value for platform in Platform} diff --git a/homeassistant/data_entry_flow.py b/homeassistant/data_entry_flow.py index 4402eadeda2997..d9e58a8dda8bce 100644 --- a/homeassistant/data_entry_flow.py +++ b/homeassistant/data_entry_flow.py @@ -5,14 +5,15 @@ import abc import asyncio from collections import defaultdict -from collections.abc import Callable, Container, Hashable, Iterable, Mapping +from collections.abc import Callable, Container, Coroutine, Hashable, Iterable, Mapping from contextlib import suppress import copy from dataclasses import dataclass from enum import StrEnum +import functools import logging from types import MappingProxyType -from typing import Any, Generic, Required, TypedDict, TypeVar, cast +from typing import Any, Concatenate, Generic, Required, TypedDict, TypeVar, cast import voluptuous as vol @@ -150,6 +151,15 @@ class FlowResult(TypedDict, Generic[_FlowContextT, _HandlerT], total=False): url: str +class ProgressStepData[_FlowResultT](TypedDict): + """Typed data for progress step tracking.""" + + tasks: dict[str, asyncio.Task[Any]] + abort_reason: str + abort_description_placeholders: Mapping[str, str] + next_step_result: _FlowResultT | None + + def _map_error_to_schema_errors( schema_errors: dict[str, Any], error: vol.Invalid, @@ -639,6 +649,12 @@ class FlowHandler(Generic[_FlowContextT, _FlowResultT, _HandlerT]): __progress_task: asyncio.Task[Any] | None = None __no_progress_task_reported = False deprecated_show_progress = False + _progress_step_data: ProgressStepData[_FlowResultT] = { + "tasks": {}, + "abort_reason": "", + "abort_description_placeholders": MappingProxyType({}), + "next_step_result": None, + } @property def source(self) -> str | None: @@ -761,6 +777,37 @@ def async_abort( description_placeholders=description_placeholders, ) + async def async_step__progress_step_abort( + self, user_input: dict[str, Any] | None = None + ) -> _FlowResultT: + """Abort the flow.""" + return self.async_abort( + reason=self._progress_step_data["abort_reason"], + description_placeholders=self._progress_step_data[ + "abort_description_placeholders" + ], + ) + + async def async_step__progress_step_progress_done( + self, user_input: dict[str, Any] | None = None + ) -> _FlowResultT: + """Progress done. Return the next step. + + Used by the progress_step decorator + to allow decorated step methods + to call the next step method, to change step, + without using async_show_progress_done. + If no next step is set, abort the flow. + """ + if self._progress_step_data["next_step_result"] is None: + return self.async_abort( + reason=self._progress_step_data["abort_reason"], + description_placeholders=self._progress_step_data[ + "abort_description_placeholders" + ], + ) + return self._progress_step_data["next_step_result"] + @callback def async_external_step( self, @@ -930,3 +977,90 @@ def __init__( def __call__(self, value: Any) -> Any: """Validate input.""" return self.schema(value) + + +type _FuncType[_T: FlowHandler[Any, Any, Any], _R: FlowResult[Any, Any], **_P] = ( + Callable[Concatenate[_T, _P], Coroutine[Any, Any, _R]] +) + + +def progress_step[ + HandlerT: FlowHandler[Any, Any, Any], + ResultT: FlowResult[Any, Any], + **P, +]( + description_placeholders: ( + dict[str, str] | Callable[[Any], dict[str, str]] | None + ) = None, +) -> Callable[[_FuncType[HandlerT, ResultT, P]], _FuncType[HandlerT, ResultT, P]]: + """Decorator to create a progress step from an async function. + + The decorated method should be a step method + which needs to show progress. + The method should accept dict[str, Any] as user_input + and should return a FlowResult or raise AbortFlow. + The method can call self.async_update_progress(progress) + to update progress. + + Args: + description_placeholders: Static dict or callable that returns dict for progress UI placeholders. + """ + + def decorator( + func: _FuncType[HandlerT, ResultT, P], + ) -> _FuncType[HandlerT, ResultT, P]: + @functools.wraps(func) + async def wrapper( + self: FlowHandler[Any, ResultT], *args: P.args, **kwargs: P.kwargs + ) -> ResultT: + step_id = func.__name__.replace("async_step_", "") + + # Check if we have a progress task running + progress_task = self._progress_step_data["tasks"].get(step_id) + + if progress_task is None: + # First call - create and start the progress task + progress_task = self.hass.async_create_task( + func(self, *args, **kwargs), # type: ignore[arg-type] + f"Progress step {step_id}", + ) + self._progress_step_data["tasks"][step_id] = progress_task + + if not progress_task.done(): + # Handle description placeholders + placeholders = None + if description_placeholders is not None: + if callable(description_placeholders): + placeholders = description_placeholders(self) + else: + placeholders = description_placeholders + + return self.async_show_progress( + step_id=step_id, + progress_action=step_id, + progress_task=progress_task, + description_placeholders=placeholders, + ) + + # Task is done or this is a subsequent call + try: + self._progress_step_data["next_step_result"] = await progress_task + except AbortFlow as err: + self._progress_step_data["abort_reason"] = err.reason + self._progress_step_data["abort_description_placeholders"] = ( + err.description_placeholders or {} + ) + return self.async_show_progress_done( + next_step_id="_progress_step_abort" + ) + finally: + # Clean up task reference + self._progress_step_data["tasks"].pop(step_id, None) + + return self.async_show_progress_done( + next_step_id="_progress_step_progress_done" + ) + + return wrapper + + return decorator diff --git a/homeassistant/generated/application_credentials.py b/homeassistant/generated/application_credentials.py index 6d41c0c379dbb8..38cd82a39d748c 100644 --- a/homeassistant/generated/application_credentials.py +++ b/homeassistant/generated/application_credentials.py @@ -6,6 +6,7 @@ APPLICATION_CREDENTIALS = [ "aladdin_connect", "august", + "ekeybionyx", "electric_kiwi", "fitbit", "geocaching", diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index a3b7aa63060fe0..5cdff221957447 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -168,6 +168,7 @@ "edl21", "efergy", "eheimdigital", + "ekeybionyx", "electrasmart", "electric_kiwi", "elevenlabs", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 1b72bed62b9618..fb4d3a1992158d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -1609,6 +1609,12 @@ "config_flow": true, "iot_class": "local_polling" }, + "ekeybionyx": { + "name": "ekey bionyx", + "integration_type": "hub", + "config_flow": true, + "iot_class": "local_push" + }, "electrasmart": { "name": "Electra Smart", "integration_type": "hub", @@ -4300,6 +4306,11 @@ "integration_type": "virtual", "supported_by": "home_connect" }, + "neo": { + "name": "Neo", + "integration_type": "virtual", + "supported_by": "shelly" + }, "ness_alarm": { "name": "Ness Alarm", "integration_type": "hub", diff --git a/homeassistant/package_constraints.txt b/homeassistant/package_constraints.txt index 227b9e3b918842..afc46ecbd6bb39 100644 --- a/homeassistant/package_constraints.txt +++ b/homeassistant/package_constraints.txt @@ -40,7 +40,7 @@ hass-nabucasa==1.1.1 hassil==3.2.0 home-assistant-bluetooth==1.13.1 home-assistant-frontend==20250903.5 -home-assistant-intents==2025.9.3 +home-assistant-intents==2025.9.24 httpx==0.28.1 ifaddr==0.2.0 Jinja2==3.1.6 diff --git a/requirements_all.txt b/requirements_all.txt index d5bcaf1a1c963b..5500f3385a32fe 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -852,6 +852,9 @@ ecoaliface==0.4.0 # homeassistant.components.eheimdigital eheimdigital==1.3.0 +# homeassistant.components.ekeybionyx +ekey-bionyxpy==1.0.0 + # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 @@ -1186,7 +1189,7 @@ holidays==0.81 home-assistant-frontend==20250903.5 # homeassistant.components.conversation -home-assistant-intents==2025.9.3 +home-assistant-intents==2025.9.24 # homeassistant.components.homematicip_cloud homematicip==2.3.0 @@ -2682,7 +2685,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.4.0 +renault-api==0.4.1 # homeassistant.components.renson renson-endura-delta==1.7.2 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index f178f419f3449c..4f0bf24d867e72 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -743,6 +743,9 @@ easyenergy==2.1.2 # homeassistant.components.eheimdigital eheimdigital==1.3.0 +# homeassistant.components.ekeybionyx +ekey-bionyxpy==1.0.0 + # homeassistant.components.electric_kiwi electrickiwi-api==0.9.14 @@ -1035,7 +1038,7 @@ holidays==0.81 home-assistant-frontend==20250903.5 # homeassistant.components.conversation -home-assistant-intents==2025.9.3 +home-assistant-intents==2025.9.24 # homeassistant.components.homematicip_cloud homematicip==2.3.0 @@ -2231,7 +2234,7 @@ refoss-ha==1.2.5 regenmaschine==2024.03.0 # homeassistant.components.renault -renault-api==0.4.0 +renault-api==0.4.1 # homeassistant.components.renson renson-endura-delta==1.7.2 diff --git a/script/hassfest/docker/Dockerfile b/script/hassfest/docker/Dockerfile index 18550535fbe727..a9f0aacdae106c 100644 --- a/script/hassfest/docker/Dockerfile +++ b/script/hassfest/docker/Dockerfile @@ -32,7 +32,7 @@ RUN --mount=from=ghcr.io/astral-sh/uv:0.8.9,source=/uv,target=/bin/uv \ go2rtc-client==0.2.1 \ ha-ffmpeg==3.2.2 \ hassil==3.2.0 \ - home-assistant-intents==2025.9.3 \ + home-assistant-intents==2025.9.24 \ mutagen==1.47.0 \ pymicro-vad==1.0.1 \ pyspeex-noise==1.0.2 diff --git a/tests/components/analytics/test_analytics.py b/tests/components/analytics/test_analytics.py index 9a63f4b29cba8b..4a98d9770e4fe7 100644 --- a/tests/components/analytics/test_analytics.py +++ b/tests/components/analytics/test_analytics.py @@ -1055,6 +1055,16 @@ async def test_devices_payload_no_entities( model_id="test-model-id7", ) + # Device from an integration with a service type + mock_service_config_entry = MockConfigEntry(domain="uptime") + mock_service_config_entry.add_to_hass(hass) + device_registry.async_get_or_create( + config_entry_id=mock_service_config_entry.entry_id, + identifiers={("device", "8")}, + manufacturer="test-manufacturer8", + model_id="test-model-id8", + ) + client = await hass_client() response = await client.get("/api/analytics/devices") assert response.status == HTTPStatus.OK @@ -1173,21 +1183,29 @@ async def test_devices_payload_with_entities( original_device_class=NumberDeviceClass.TEMPERATURE, ) hass.states.async_set("number.hue_1", "2") - # Helper entity with assumed state + # Entity with assumed state entity_registry.async_get_or_create( domain="light", - platform="template", + platform="hue", + unique_id="2", + device_id=device_entry.id, + has_entity_name=True, + ) + hass.states.async_set("light.hue_2", "on", {ATTR_ASSUMED_STATE: True}) + # Entity from a different integration + entity_registry.async_get_or_create( + domain="light", + platform="roomba", unique_id="1", device_id=device_entry.id, has_entity_name=True, ) - hass.states.async_set("light.template_1", "on", {ATTR_ASSUMED_STATE: True}) # Second device entity_registry.async_get_or_create( domain="light", platform="hue", - unique_id="2", + unique_id="3", device_id=device_entry_2.id, ) @@ -1235,6 +1253,16 @@ async def test_devices_payload_with_entities( "original_device_class": "temperature", "unit_of_measurement": None, }, + { + "assumed_state": True, + "capabilities": None, + "domain": "light", + "entity_category": None, + "has_entity_name": True, + "modified_by_integration": None, + "original_device_class": None, + "unit_of_measurement": None, + }, ], "entry_type": None, "has_configuration_url": False, @@ -1281,11 +1309,11 @@ async def test_devices_payload_with_entities( }, ], }, - "template": { + "roomba": { "devices": [], "entities": [ { - "assumed_state": True, + "assumed_state": None, "capabilities": None, "domain": "light", "entity_category": None, diff --git a/tests/components/automation/test_analytics.py b/tests/components/automation/test_analytics.py deleted file mode 100644 index 803103d0245ca0..00000000000000 --- a/tests/components/automation/test_analytics.py +++ /dev/null @@ -1,41 +0,0 @@ -"""Tests for analytics platform.""" - -import pytest - -from homeassistant.components.analytics import async_devices_payload -from homeassistant.components.automation import DOMAIN -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component - - -@pytest.mark.asyncio -async def test_analytics( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test the analytics platform.""" - await async_setup_component(hass, "analytics", {}) - - entity_registry.async_get_or_create( - domain="automation", - platform="automation", - unique_id="automation1", - suggested_object_id="automation1", - capabilities={"id": "automation1"}, - ) - - result = await async_devices_payload(hass) - assert result["integrations"][DOMAIN]["entities"] == [ - { - "assumed_state": None, - "capabilities": None, - "domain": "automation", - "entity_category": None, - "has_entity_name": False, - "modified_by_integration": [ - "capabilities", - ], - "original_device_class": None, - "unit_of_measurement": None, - }, - ] diff --git a/tests/components/conversation/test_default_agent.py b/tests/components/conversation/test_default_agent.py index 2db9dd9fc36e47..8356274a41ea59 100644 --- a/tests/components/conversation/test_default_agent.py +++ b/tests/components/conversation/test_default_agent.py @@ -2542,7 +2542,7 @@ async def test_non_default_response(hass: HomeAssistant, init_components) -> Non ) ) assert len(calls) == 1 - assert result.response.speech["plain"]["speech"] == "Opened" + assert result.response.speech["plain"]["speech"] == "Opening" async def test_turn_on_area( diff --git a/tests/components/conversation/test_default_agent_intents.py b/tests/components/conversation/test_default_agent_intents.py index 2b0e9f30190ab2..8828cc4bd1e66a 100644 --- a/tests/components/conversation/test_default_agent_intents.py +++ b/tests/components/conversation/test_default_agent_intents.py @@ -90,7 +90,7 @@ async def test_cover_set_position( response = result.response assert response.response_type == intent.IntentResponseType.ACTION_DONE - assert response.speech["plain"]["speech"] == "Opened" + assert response.speech["plain"]["speech"] == "Opening" assert len(calls) == 1 call = calls[0] assert call.data == {"entity_id": entity_id} @@ -104,7 +104,7 @@ async def test_cover_set_position( response = result.response assert response.response_type == intent.IntentResponseType.ACTION_DONE - assert response.speech["plain"]["speech"] == "Closed" + assert response.speech["plain"]["speech"] == "Closing" assert len(calls) == 1 call = calls[0] assert call.data == {"entity_id": entity_id} @@ -146,7 +146,7 @@ async def test_cover_device_class( response = result.response assert response.response_type == intent.IntentResponseType.ACTION_DONE - assert response.speech["plain"]["speech"] == "Opened the garage" + assert response.speech["plain"]["speech"] == "Opening the garage" assert len(calls) == 1 call = calls[0] assert call.data == {"entity_id": entity_id} @@ -170,7 +170,7 @@ async def test_valve_intents( response = result.response assert response.response_type == intent.IntentResponseType.ACTION_DONE - assert response.speech["plain"]["speech"] == "Opened" + assert response.speech["plain"]["speech"] == "Opening" assert len(calls) == 1 call = calls[0] assert call.data == {"entity_id": entity_id} @@ -184,7 +184,7 @@ async def test_valve_intents( response = result.response assert response.response_type == intent.IntentResponseType.ACTION_DONE - assert response.speech["plain"]["speech"] == "Closed" + assert response.speech["plain"]["speech"] == "Closing" assert len(calls) == 1 call = calls[0] assert call.data == {"entity_id": entity_id} diff --git a/tests/components/ekeybionyx/__init__.py b/tests/components/ekeybionyx/__init__.py new file mode 100644 index 00000000000000..334b000c57b488 --- /dev/null +++ b/tests/components/ekeybionyx/__init__.py @@ -0,0 +1 @@ +"""Tests for the Ekey Bionyx integration.""" diff --git a/tests/components/ekeybionyx/conftest.py b/tests/components/ekeybionyx/conftest.py new file mode 100644 index 00000000000000..b6fc9be1572c4e --- /dev/null +++ b/tests/components/ekeybionyx/conftest.py @@ -0,0 +1,173 @@ +"""Conftest module for ekeybionyx.""" + +from http import HTTPStatus +from unittest.mock import patch + +import pytest + +from homeassistant.components.ekeybionyx.const import DOMAIN +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry +from tests.test_util.aiohttp import AiohttpClientMocker + + +def dummy_systems( + num_systems: int, free_wh: int, used_wh: int, own_system: bool = True +) -> list[dict]: + """Create dummy systems.""" + return [ + { + "systemName": f"System {i + 1}", + "systemId": f"946DA01F-9ABD-4D9D-80C7-02AF85C822A{i + 8}", + "ownSystem": own_system, + "functionWebhookQuotas": {"free": free_wh, "used": used_wh}, + } + for i in range(num_systems) + ] + + +@pytest.fixture(name="system") +def mock_systems( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + json=dummy_systems(2, 5, 0), + ) + + +@pytest.fixture(name="no_own_system") +def mock_no_own_systems( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + json=dummy_systems(1, 1, 0, False), + ) + + +@pytest.fixture(name="no_response") +def mock_no_response( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + status=HTTPStatus.INTERNAL_SERVER_ERROR, + ) + + +@pytest.fixture(name="no_available_webhooks") +def mock_no_available_webhooks( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + json=dummy_systems(1, 0, 0), + ) + + +@pytest.fixture(name="already_set_up") +def mock_already_set_up( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + json=dummy_systems(1, 0, 1), + ) + + +@pytest.fixture(name="webhooks") +def mock_webhooks( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems/946DA01F-9ABD-4D9D-80C7-02AF85C822A8/function-webhooks", + json=[ + { + "functionWebhookId": "946DA01F-9ABD-4D9D-80C7-02AF85C822B9", + "integrationName": "Home Assistant", + "locationName": "A simple string containing 0 to 128 word, space and punctuation characters.", + "functionName": "A simple string containing 0 to 50 word, space and punctuation characters.", + "expiresAt": "2022-05-16T04:11:28.0000000+00:00", + "modificationState": None, + } + ], + ) + + +@pytest.fixture(name="webhook_deletion") +def mock_webhook_deletion( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.delete( + "https://api.bionyx.io/3rd-party/api/systems/946DA01F-9ABD-4D9D-80C7-02AF85C822A8/function-webhooks/946DA01F-9ABD-4D9D-80C7-02AF85C822B9", + status=HTTPStatus.ACCEPTED, + ) + + +@pytest.fixture(name="add_webhook", autouse=True) +def mock_add_webhook( + aioclient_mock: AiohttpClientMocker, +) -> None: + """Fixture to setup fake requests made to Ekey Bionyx API during config flow.""" + aioclient_mock.post( + "https://api.bionyx.io/3rd-party/api/systems/946DA01F-9ABD-4D9D-80C7-02AF85C822A8/function-webhooks", + status=HTTPStatus.CREATED, + json={ + "functionWebhookId": "946DA01F-9ABD-4D9D-80C7-02AF85C822A8", + "integrationName": "Home Assistant", + "locationName": "Home Assistant", + "functionName": "Test", + "expiresAt": "2022-05-16T04:11:28.0000000+00:00", + "modificationState": None, + }, + ) + + +@pytest.fixture(name="webhook_id") +def mock_webhook_id(): + """Mock webhook_id.""" + with patch( + "homeassistant.components.webhook.async_generate_id", return_value="1234567890" + ): + yield + + +@pytest.fixture(name="token_hex") +def mock_token_hex(): + """Mock auth property.""" + with patch( + "secrets.token_hex", + return_value="f2156edca7fc6871e13845314a6fc68622e5ad7c58f17663a487ed28cac247f7", + ): + yield + + +@pytest.fixture(name="config_entry") +def mock_config_entry(hass: HomeAssistant) -> MockConfigEntry: + """Create mocked config entry.""" + return MockConfigEntry( + title="test@test.com", + domain=DOMAIN, + data={ + "webhooks": [ + { + "webhook_id": "a2156edca7fb6671e13845314f6fc68622e5dd7c58f17663a487bd28cac247e7", + "name": "Test1", + "auth": "f2156edca7fc6871e13845314a6fc68622e5ad7c58f17663a487ed28cac247f7", + "ekey_id": "946DA01F-9ABD-4D9D-80C7-02AF85C822A8", + } + ] + }, + unique_id="946DA01F-9ABD-4D9D-80C7-02AF85C822A8", + version=1, + minor_version=1, + ) diff --git a/tests/components/ekeybionyx/test_config_flow.py b/tests/components/ekeybionyx/test_config_flow.py new file mode 100644 index 00000000000000..f50cd099dbc431 --- /dev/null +++ b/tests/components/ekeybionyx/test_config_flow.py @@ -0,0 +1,360 @@ +"""Test the ekey bionyx config flow.""" + +from unittest.mock import patch + +import pytest + +from homeassistant import config_entries +from homeassistant.components.application_credentials import ( + ClientCredential, + async_import_client_credential, +) +from homeassistant.components.ekeybionyx.const import ( + DOMAIN, + OAUTH2_AUTHORIZE, + OAUTH2_TOKEN, + SCOPE, +) +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType +from homeassistant.helpers import config_entry_oauth2_flow +from homeassistant.setup import async_setup_component + +from .conftest import dummy_systems + +from tests.test_util.aiohttp import AiohttpClientMocker +from tests.typing import ClientSessionGenerator + +CLIENT_ID = "1234" +CLIENT_SECRET = "5678" + + +@pytest.fixture +async def setup_credentials(hass: HomeAssistant) -> None: + """Fixture to setup credentials.""" + assert await async_setup_component(hass, "application_credentials", {}) + await async_import_client_credential( + hass, + DOMAIN, + ClientCredential(CLIENT_ID, CLIENT_SECRET), + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_full_flow( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + webhook_id: None, + system: None, + token_hex: None, +) -> None: + """Check full flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + f"&scope={SCOPE}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + flow = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert flow.get("step_id") == "choose_system" + + flow2 = await hass.config_entries.flow.async_configure( + flow["flow_id"], {"system": "946DA01F-9ABD-4D9D-80C7-02AF85C822A8"} + ) + assert flow2.get("step_id") == "webhooks" + + flow3 = await hass.config_entries.flow.async_configure( + flow2["flow_id"], + { + "url": "localhost:8123", + }, + ) + + assert flow3.get("errors") == {"base": "no_webhooks_provided", "url": "invalid_url"} + + flow4 = await hass.config_entries.flow.async_configure( + flow3["flow_id"], + { + "webhook1": "Test ", + "webhook2": " Invalid", + "webhook3": "1Invalid", + "webhook4": "Also@Invalid", + "webhook5": "Invalid-Name", + "url": "localhost:8123", + }, + ) + + assert flow4.get("errors") == { + "url": "invalid_url", + "webhook1": "invalid_name", + "webhook2": "invalid_name", + "webhook3": "invalid_name", + "webhook4": "invalid_name", + "webhook5": "invalid_name", + } + + with patch( + "homeassistant.components.ekeybionyx.async_setup_entry", return_value=True + ) as mock_setup: + flow5 = await hass.config_entries.flow.async_configure( + flow2["flow_id"], + { + "webhook1": "Test", + "url": "http://localhost:8123", + }, + ) + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert hass.config_entries.async_entries(DOMAIN)[0].data == { + "webhooks": [ + { + "webhook_id": "1234567890", + "name": "Test", + "auth": "f2156edca7fc6871e13845314a6fc68622e5ad7c58f17663a487ed28cac247f7", + "ekey_id": "946DA01F-9ABD-4D9D-80C7-02AF85C822A8", + } + ] + } + + assert flow5.get("type") is FlowResultType.CREATE_ENTRY + + assert len(mock_setup.mock_calls) == 1 + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_no_own_system( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + no_own_system: None, +) -> None: + """Check no own System flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + f"&scope={SCOPE}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + flow = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + assert flow.get("type") is FlowResultType.ABORT + assert flow.get("reason") == "no_own_systems" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_no_available_webhooks( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + no_available_webhooks: None, +) -> None: + """Check no own System flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + f"&scope={SCOPE}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + flow = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + assert flow.get("type") is FlowResultType.ABORT + assert flow.get("reason") == "no_available_webhooks" + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_cleanup( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + already_set_up: None, + webhooks: None, + webhook_deletion: None, +) -> None: + """Check no own System flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + f"&scope={SCOPE}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + + flow = await hass.config_entries.flow.async_configure(result["flow_id"]) + assert flow.get("step_id") == "delete_webhooks" + + flow2 = await hass.config_entries.flow.async_configure(flow["flow_id"], {}) + assert flow2.get("type") is FlowResultType.SHOW_PROGRESS + + aioclient_mock.clear_requests() + + aioclient_mock.get( + "https://api.bionyx.io/3rd-party/api/systems", + json=dummy_systems(1, 1, 0), + ) + + await hass.async_block_till_done() + + assert ( + hass.config_entries.flow.async_get(flow2["flow_id"]).get("step_id") + == "webhooks" + ) + + +@pytest.mark.usefixtures("current_request_with_host") +async def test_error_on_setup( + hass: HomeAssistant, + hass_client_no_auth: ClientSessionGenerator, + aioclient_mock: AiohttpClientMocker, + setup_credentials: None, + no_response: None, +) -> None: + """Check no own System flow.""" + result = await hass.config_entries.flow.async_init( + DOMAIN, context={"source": config_entries.SOURCE_USER} + ) + state = config_entry_oauth2_flow._encode_jwt( + hass, + { + "flow_id": result["flow_id"], + "redirect_uri": "https://example.com/auth/external/callback", + }, + ) + + assert result["url"] == ( + f"{OAUTH2_AUTHORIZE}?response_type=code&client_id={CLIENT_ID}" + "&redirect_uri=https://example.com/auth/external/callback" + f"&state={state}" + f"&scope={SCOPE}" + ) + + client = await hass_client_no_auth() + resp = await client.get(f"/auth/external/callback?code=abcd&state={state}") + assert resp.status == 200 + assert resp.headers["content-type"] == "text/html; charset=utf-8" + + aioclient_mock.post( + OAUTH2_TOKEN, + json={ + "refresh_token": "mock-refresh-token", + "access_token": "mock-access-token", + "type": "Bearer", + "expires_in": 60, + }, + ) + flow = await hass.config_entries.flow.async_configure(result["flow_id"]) + + assert len(hass.config_entries.async_entries(DOMAIN)) == 0 + + assert flow.get("type") is FlowResultType.ABORT + assert flow.get("reason") == "cannot_connect" diff --git a/tests/components/ekeybionyx/test_init.py b/tests/components/ekeybionyx/test_init.py new file mode 100644 index 00000000000000..992d60c303440e --- /dev/null +++ b/tests/components/ekeybionyx/test_init.py @@ -0,0 +1,30 @@ +"""Module contains tests for the ekeybionyx component's initialization. + +Functions: + test_async_setup_entry(hass: HomeAssistant, config_entry: MockConfigEntry) -> None: + Test a successful setup entry and unload of entry. +""" + +from homeassistant.components.ekeybionyx.const import DOMAIN +from homeassistant.config_entries import ConfigEntryState +from homeassistant.core import HomeAssistant + +from tests.common import MockConfigEntry + + +async def test_async_setup_entry( + hass: HomeAssistant, config_entry: MockConfigEntry +) -> None: + """Test a successful setup entry and unload of entry.""" + config_entry.add_to_hass(hass) + + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + + assert len(hass.config_entries.async_entries(DOMAIN)) == 1 + assert config_entry.state is ConfigEntryState.LOADED + + assert await hass.config_entries.async_unload(config_entry.entry_id) + await hass.async_block_till_done() + + assert config_entry.state is ConfigEntryState.NOT_LOADED diff --git a/tests/components/homeassistant_connect_zbt2/test_config_flow.py b/tests/components/homeassistant_connect_zbt2/test_config_flow.py index e3b4f7a66f52e6..ff26c246a40f81 100644 --- a/tests/components/homeassistant_connect_zbt2/test_config_flow.py +++ b/tests/components/homeassistant_connect_zbt2/test_config_flow.py @@ -34,6 +34,16 @@ def mock_supervisor_fixture() -> Generator[None]: yield +@pytest.fixture(name="setup_entry", autouse=True) +def setup_entry_fixture() -> Generator[AsyncMock]: + """Mock entry setup.""" + with patch( + "homeassistant.components.homeassistant_connect_zbt2.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + async def test_config_flow_zigbee( hass: HomeAssistant, ) -> None: @@ -177,19 +187,12 @@ async def mock_install_firmware_step( # Make sure the flow continues when the progress task is done. await hass.async_block_till_done() - confirm_result = await hass.config_entries.flow.async_configure( - result["flow_id"] - ) - - assert start_addon.call_count == 1 - assert start_addon.call_args == call("core_openthread_border_router") - assert confirm_result["type"] is FlowResultType.FORM - assert confirm_result["step_id"] == "confirm_otbr" - create_result = await hass.config_entries.flow.async_configure( - confirm_result["flow_id"], user_input={} + result["flow_id"] ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") assert create_result["type"] is FlowResultType.CREATE_ENTRY config_entry = create_result["result"] assert config_entry.data == { diff --git a/tests/components/homeassistant_hardware/test_config_flow.py b/tests/components/homeassistant_hardware/test_config_flow.py index c7c2535e372733..296e067ae6b302 100644 --- a/tests/components/homeassistant_hardware/test_config_flow.py +++ b/tests/components/homeassistant_hardware/test_config_flow.py @@ -705,7 +705,7 @@ async def test_config_flow_thread( await hass.async_block_till_done(wait_background_tasks=True) # Progress the flow, it is now installing firmware - confirm_otbr_result = await consume_progress_flow( + create_result = await consume_progress_flow( hass, flow_id=pick_result["flow_id"], valid_step_ids=( @@ -717,9 +717,6 @@ async def test_config_flow_thread( ) # Installation will conclude with the config entry being created - create_result = await hass.config_entries.flow.async_configure( - confirm_otbr_result["flow_id"], user_input={} - ) assert create_result["type"] is FlowResultType.CREATE_ENTRY config_entry = create_result["result"] @@ -766,7 +763,7 @@ async def test_config_flow_thread_addon_already_installed( ) # Progress - confirm_otbr_result = await consume_progress_flow( + create_result = await consume_progress_flow( hass, flow_id=pick_result["flow_id"], valid_step_ids=( @@ -776,35 +773,26 @@ async def test_config_flow_thread_addon_already_installed( ), ) - # We're now waiting to confirm OTBR - assert confirm_otbr_result["type"] is FlowResultType.FORM - assert confirm_otbr_result["step_id"] == "confirm_otbr" - - # The addon has been installed - assert set_addon_options.call_args == call( - "core_openthread_border_router", - AddonsOptions( - config={ - "device": "/dev/SomeDevice123", - "baudrate": 460800, - "flow_control": True, - "autoflash_firmware": False, - }, - ), - ) - assert start_addon.call_count == 1 - assert start_addon.call_args == call("core_openthread_border_router") - - # Finally, create the config entry - create_result = await hass.config_entries.flow.async_configure( - confirm_otbr_result["flow_id"], user_input={} - ) - assert create_result["type"] is FlowResultType.CREATE_ENTRY - assert create_result["result"].data == { - "firmware": "spinel", - "device": TEST_DEVICE, - "hardware": TEST_HARDWARE_NAME, - } + # The add-on has been installed + assert set_addon_options.call_args == call( + "core_openthread_border_router", + AddonsOptions( + config={ + "device": "/dev/SomeDevice123", + "baudrate": 460800, + "flow_control": True, + "autoflash_firmware": False, + }, + ), + ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") + assert create_result["type"] is FlowResultType.CREATE_ENTRY + assert create_result["result"].data == { + "firmware": "spinel", + "device": TEST_DEVICE, + "hardware": TEST_HARDWARE_NAME, + } @pytest.mark.usefixtures("addon_not_installed") @@ -856,7 +844,7 @@ async def test_options_flow_zigbee_to_thread( assert result["type"] is FlowResultType.SHOW_PROGRESS assert result["step_id"] == "install_otbr_addon" - assert result["progress_action"] == "install_addon" + assert result["progress_action"] == "install_otbr_addon" await hass.async_block_till_done(wait_background_tasks=True) @@ -870,33 +858,26 @@ async def test_options_flow_zigbee_to_thread( result = await hass.config_entries.options.async_configure(result["flow_id"]) - assert result["type"] is FlowResultType.FORM - assert result["step_id"] == "confirm_otbr" - assert install_addon.call_count == 1 - assert install_addon.call_args == call("core_openthread_border_router") - assert set_addon_options.call_count == 1 - assert set_addon_options.call_args == call( - "core_openthread_border_router", - AddonsOptions( - config={ - "device": "/dev/SomeDevice123", - "baudrate": 460800, - "flow_control": True, - "autoflash_firmware": False, - }, - ), - ) - assert start_addon.call_count == 1 - assert start_addon.call_args == call("core_openthread_border_router") - - # We are now done - result = await hass.config_entries.options.async_configure( - result["flow_id"], user_input={} - ) - assert result["type"] is FlowResultType.CREATE_ENTRY + assert install_addon.call_count == 1 + assert install_addon.call_args == call("core_openthread_border_router") + assert set_addon_options.call_count == 1 + assert set_addon_options.call_args == call( + "core_openthread_border_router", + AddonsOptions( + config={ + "device": "/dev/SomeDevice123", + "baudrate": 460800, + "flow_control": True, + "autoflash_firmware": False, + }, + ), + ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") + assert result["type"] is FlowResultType.CREATE_ENTRY - # The firmware type has been updated - assert config_entry.data["firmware"] == "spinel" + # The firmware type has been updated + assert config_entry.data["firmware"] == "spinel" @pytest.mark.usefixtures("addon_store_info") diff --git a/tests/components/homeassistant_sky_connect/test_config_flow.py b/tests/components/homeassistant_sky_connect/test_config_flow.py index 2b863450d7dff2..d977a2ba8a14a3 100644 --- a/tests/components/homeassistant_sky_connect/test_config_flow.py +++ b/tests/components/homeassistant_sky_connect/test_config_flow.py @@ -40,6 +40,16 @@ def mock_supervisor_fixture() -> Generator[None]: yield +@pytest.fixture(name="setup_entry", autouse=True) +def setup_entry_fixture() -> Generator[AsyncMock]: + """Mock entry setup.""" + with patch( + "homeassistant.components.homeassistant_sky_connect.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + @pytest.mark.parametrize( ("usb_data", "model"), [ @@ -210,19 +220,12 @@ async def mock_install_firmware_step( # Make sure the flow continues when the progress task is done. await hass.async_block_till_done() - confirm_result = await hass.config_entries.flow.async_configure( - result["flow_id"] - ) - - assert start_addon.call_count == 1 - assert start_addon.call_args == call("core_openthread_border_router") - assert confirm_result["type"] is FlowResultType.FORM - assert confirm_result["step_id"] == ("confirm_otbr") - create_result = await hass.config_entries.flow.async_configure( - confirm_result["flow_id"], user_input={} + result["flow_id"] ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") assert create_result["type"] is FlowResultType.CREATE_ENTRY config_entry = create_result["result"] assert config_entry.data == { diff --git a/tests/components/homeassistant_yellow/test_config_flow.py b/tests/components/homeassistant_yellow/test_config_flow.py index 518a1d3b4d1913..df4bee29eab090 100644 --- a/tests/components/homeassistant_yellow/test_config_flow.py +++ b/tests/components/homeassistant_yellow/test_config_flow.py @@ -76,6 +76,16 @@ def mock_reboot_host(supervisor_client: AsyncMock) -> AsyncMock: return supervisor_client.host.reboot +@pytest.fixture(name="setup_entry", autouse=True) +def setup_entry_fixture() -> Generator[AsyncMock]: + """Mock entry setup.""" + with patch( + "homeassistant.components.homeassistant_yellow.async_setup_entry", + return_value=True, + ) as mock_setup_entry: + yield mock_setup_entry + + async def test_config_flow(hass: HomeAssistant) -> None: """Test the config flow.""" mock_integration(hass, MockModule("hassio")) @@ -477,21 +487,13 @@ async def mock_install_firmware_step( # Make sure the flow continues when the progress task is done. await hass.async_block_till_done() - confirm_result = await hass.config_entries.options.async_configure( - result["flow_id"] - ) - - assert start_addon.call_count == 1 - assert start_addon.call_args == call("core_openthread_border_router") - assert confirm_result["type"] is FlowResultType.FORM - assert confirm_result["step_id"] == ("confirm_otbr") - create_result = await hass.config_entries.options.async_configure( - confirm_result["flow_id"], user_input={} + result["flow_id"] ) + assert start_addon.call_count == 1 + assert start_addon.call_args == call("core_openthread_border_router") assert create_result["type"] is FlowResultType.CREATE_ENTRY - assert config_entry.data == { "firmware": fw_type.value, "firmware_version": fw_version, diff --git a/tests/components/smartthings/conftest.py b/tests/components/smartthings/conftest.py index b28a7c761f4ce0..c45417122e92dc 100644 --- a/tests/components/smartthings/conftest.py +++ b/tests/components/smartthings/conftest.py @@ -99,7 +99,6 @@ def mock_smartthings() -> Generator[AsyncMock]: "aq_sensor_3_ikea", "da_ac_airsensor_01001", "da_ac_rac_000001", - "da_ac_rac_000002", "da_ac_rac_000003", "da_ac_rac_100001", "da_ac_rac_01001", diff --git a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000002.json b/tests/components/smartthings/fixtures/device_status/da_ac_rac_000002.json deleted file mode 100644 index 1dce4ae52614cf..00000000000000 --- a/tests/components/smartthings/fixtures/device_status/da_ac_rac_000002.json +++ /dev/null @@ -1,886 +0,0 @@ -{ - "components": { - "1": { - "relativeHumidityMeasurement": { - "humidity": { - "value": 0, - "unit": "%", - "timestamp": "2021-04-06T16:43:35.291Z" - } - }, - "custom.airConditionerOdorController": { - "airConditionerOdorControllerProgress": { - "value": null, - "timestamp": "2021-04-08T04:11:38.269Z" - }, - "airConditionerOdorControllerState": { - "value": null, - "timestamp": "2021-04-08T04:11:38.269Z" - } - }, - "custom.thermostatSetpointControl": { - "minimumSetpoint": { - "value": null, - "timestamp": "2021-04-08T04:04:19.901Z" - }, - "maximumSetpoint": { - "value": null, - "timestamp": "2021-04-08T04:04:19.901Z" - } - }, - "airConditionerMode": { - "availableAcModes": { - "value": null - }, - "supportedAcModes": { - "value": null, - "timestamp": "2021-04-08T03:50:50.930Z" - }, - "airConditionerMode": { - "value": null, - "timestamp": "2021-04-08T03:50:50.930Z" - } - }, - "custom.spiMode": { - "spiMode": { - "value": null, - "timestamp": "2021-04-06T16:57:57.686Z" - } - }, - "airQualitySensor": { - "airQuality": { - "value": null, - "unit": "CAQI", - "timestamp": "2021-04-06T16:57:57.602Z" - } - }, - "custom.airConditionerOptionalMode": { - "supportedAcOptionalMode": { - "value": null, - "timestamp": "2021-04-06T16:57:57.659Z" - }, - "acOptionalMode": { - "value": null, - "timestamp": "2021-04-06T16:57:57.659Z" - } - }, - "switch": { - "switch": { - "value": null, - "timestamp": "2021-04-06T16:44:10.518Z" - } - }, - "custom.airConditionerTropicalNightMode": { - "acTropicalNightModeLevel": { - "value": null, - "timestamp": "2021-04-06T16:44:10.498Z" - } - }, - "ocf": { - "st": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mndt": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnfv": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnhw": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "di": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnsl": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "dmv": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "n": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnmo": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "vid": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnmn": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnml": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnpv": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "mnos": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "pi": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - }, - "icv": { - "value": null, - "timestamp": "2021-04-06T16:44:10.472Z" - } - }, - "airConditionerFanMode": { - "fanMode": { - "value": null, - "timestamp": "2021-04-06T16:44:10.381Z" - }, - "supportedAcFanModes": { - "value": ["auto", "low", "medium", "high", "turbo"], - "timestamp": "2024-09-10T10:26:28.605Z" - }, - "availableAcFanModes": { - "value": null - } - }, - "custom.disabledCapabilities": { - "disabledCapabilities": { - "value": [ - "remoteControlStatus", - "airQualitySensor", - "dustSensor", - "odorSensor", - "veryFineDustSensor", - "custom.dustFilter", - "custom.deodorFilter", - "custom.deviceReportStateConfiguration", - "audioVolume", - "custom.autoCleaningMode", - "custom.airConditionerTropicalNightMode", - "custom.airConditionerOdorController", - "demandResponseLoadControl", - "relativeHumidityMeasurement" - ], - "timestamp": "2024-09-10T10:26:28.605Z" - } - }, - "fanOscillationMode": { - "supportedFanOscillationModes": { - "value": null, - "timestamp": "2021-04-06T16:44:10.325Z" - }, - "availableFanOscillationModes": { - "value": null - }, - "fanOscillationMode": { - "value": "fixed", - "timestamp": "2025-02-08T00:44:53.247Z" - } - }, - "temperatureMeasurement": { - "temperatureRange": { - "value": null - }, - "temperature": { - "value": null, - "timestamp": "2021-04-06T16:44:10.373Z" - } - }, - "dustSensor": { - "dustLevel": { - "value": null, - "unit": "\u03bcg/m^3", - "timestamp": "2021-04-06T16:44:10.122Z" - }, - "fineDustLevel": { - "value": null, - "unit": "\u03bcg/m^3", - "timestamp": "2021-04-06T16:44:10.122Z" - } - }, - "custom.deviceReportStateConfiguration": { - "reportStateRealtimePeriod": { - "value": null, - "timestamp": "2021-04-06T16:44:09.800Z" - }, - "reportStateRealtime": { - "value": null, - "timestamp": "2021-04-06T16:44:09.800Z" - }, - "reportStatePeriod": { - "value": null, - "timestamp": "2021-04-06T16:44:09.800Z" - } - }, - "thermostatCoolingSetpoint": { - "coolingSetpointRange": { - "value": null - }, - "coolingSetpoint": { - "value": null, - "timestamp": "2021-04-06T16:43:59.136Z" - } - }, - "demandResponseLoadControl": { - "drlcStatus": { - "value": null, - "timestamp": "2021-04-06T16:43:54.748Z" - } - }, - "audioVolume": { - "volume": { - "value": null, - "unit": "%", - "timestamp": "2021-04-06T16:43:53.541Z" - } - }, - "powerConsumptionReport": { - "powerConsumption": { - "value": null, - "timestamp": "2021-04-06T16:43:53.364Z" - } - }, - "custom.autoCleaningMode": { - "supportedAutoCleaningModes": { - "value": null - }, - "timedCleanDuration": { - "value": null - }, - "operatingState": { - "value": null - }, - "timedCleanDurationRange": { - "value": null - }, - "supportedOperatingStates": { - "value": null - }, - "progress": { - "value": null - }, - "autoCleaningMode": { - "value": null, - "timestamp": "2021-04-06T16:43:53.344Z" - } - }, - "custom.dustFilter": { - "dustFilterUsageStep": { - "value": null, - "timestamp": "2021-04-06T16:43:39.145Z" - }, - "dustFilterUsage": { - "value": null, - "timestamp": "2021-04-06T16:43:39.145Z" - }, - "dustFilterLastResetDate": { - "value": null, - "timestamp": "2021-04-06T16:43:39.145Z" - }, - "dustFilterStatus": { - "value": null, - "timestamp": "2021-04-06T16:43:39.145Z" - }, - "dustFilterCapacity": { - "value": null, - "timestamp": "2021-04-06T16:43:39.145Z" - }, - "dustFilterResetType": { - "value": null, - "timestamp": "2021-04-06T16:43:39.145Z" - } - }, - "odorSensor": { - "odorLevel": { - "value": null, - "timestamp": "2021-04-06T16:43:38.992Z" - } - }, - "remoteControlStatus": { - "remoteControlEnabled": { - "value": null, - "timestamp": "2021-04-06T16:43:39.097Z" - } - }, - "custom.deodorFilter": { - "deodorFilterCapacity": { - "value": null, - "timestamp": "2021-04-06T16:43:39.118Z" - }, - "deodorFilterLastResetDate": { - "value": null, - "timestamp": "2021-04-06T16:43:39.118Z" - }, - "deodorFilterStatus": { - "value": null, - "timestamp": "2021-04-06T16:43:39.118Z" - }, - "deodorFilterResetType": { - "value": null, - "timestamp": "2021-04-06T16:43:39.118Z" - }, - "deodorFilterUsage": { - "value": null, - "timestamp": "2021-04-06T16:43:39.118Z" - }, - "deodorFilterUsageStep": { - "value": null, - "timestamp": "2021-04-06T16:43:39.118Z" - } - }, - "custom.energyType": { - "energyType": { - "value": null, - "timestamp": "2021-04-06T16:43:38.843Z" - }, - "energySavingSupport": { - "value": null - }, - "drMaxDuration": { - "value": null - }, - "energySavingLevel": { - "value": null - }, - "energySavingInfo": { - "value": null - }, - "supportedEnergySavingLevels": { - "value": null - }, - "energySavingOperation": { - "value": null - }, - "notificationTemplateID": { - "value": null - }, - "energySavingOperationSupport": { - "value": null - } - }, - "veryFineDustSensor": { - "veryFineDustLevel": { - "value": null, - "unit": "\u03bcg/m^3", - "timestamp": "2021-04-06T16:43:38.529Z" - } - } - }, - "main": { - "relativeHumidityMeasurement": { - "humidity": { - "value": 60, - "unit": "%", - "timestamp": "2024-12-30T13:10:23.759Z" - } - }, - "custom.airConditionerOdorController": { - "airConditionerOdorControllerProgress": { - "value": null, - "timestamp": "2021-04-06T16:43:37.555Z" - }, - "airConditionerOdorControllerState": { - "value": null, - "timestamp": "2021-04-06T16:43:37.555Z" - } - }, - "custom.thermostatSetpointControl": { - "minimumSetpoint": { - "value": 16, - "unit": "C", - "timestamp": "2025-01-08T06:30:58.307Z" - }, - "maximumSetpoint": { - "value": 30, - "unit": "C", - "timestamp": "2024-09-10T10:26:28.781Z" - } - }, - "airConditionerMode": { - "availableAcModes": { - "value": null - }, - "supportedAcModes": { - "value": ["cool", "dry", "wind", "auto", "heat"], - "timestamp": "2024-09-10T10:26:28.781Z" - }, - "airConditionerMode": { - "value": "heat", - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "custom.spiMode": { - "spiMode": { - "value": "off", - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "samsungce.dongleSoftwareInstallation": { - "status": { - "value": "completed", - "timestamp": "2021-12-29T01:36:51.289Z" - } - }, - "samsungce.deviceIdentification": { - "micomAssayCode": { - "value": null - }, - "modelName": { - "value": null - }, - "serialNumber": { - "value": null - }, - "serialNumberExtra": { - "value": null - }, - "modelClassificationCode": { - "value": null - }, - "description": { - "value": null - }, - "releaseYear": { - "value": null - }, - "binaryId": { - "value": "ARTIK051_KRAC_18K", - "timestamp": "2025-02-08T00:44:53.855Z" - } - }, - "airQualitySensor": { - "airQuality": { - "value": null, - "unit": "CAQI", - "timestamp": "2021-04-06T16:43:37.208Z" - } - }, - "custom.airConditionerOptionalMode": { - "supportedAcOptionalMode": { - "value": [ - "off", - "sleep", - "quiet", - "speed", - "windFree", - "windFreeSleep" - ], - "timestamp": "2024-09-10T10:26:28.781Z" - }, - "acOptionalMode": { - "value": "windFree", - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "switch": { - "switch": { - "value": "off", - "timestamp": "2025-02-09T16:37:54.072Z" - } - }, - "custom.airConditionerTropicalNightMode": { - "acTropicalNightModeLevel": { - "value": 0, - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "ocf": { - "st": { - "value": null, - "timestamp": "2021-04-06T16:43:35.933Z" - }, - "mndt": { - "value": null, - "timestamp": "2021-04-06T16:43:35.912Z" - }, - "mnfv": { - "value": "0.1.0", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "mnhw": { - "value": "1.0", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "di": { - "value": "13549124-3320-4fda-8e5c-3f363e043034", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "mnsl": { - "value": null, - "timestamp": "2021-04-06T16:43:35.803Z" - }, - "dmv": { - "value": "res.1.1.0,sh.1.1.0", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "n": { - "value": "[room a/c] Samsung", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "mnmo": { - "value": "ARTIK051_KRAC_18K|10193441|60010132001111110200000000000000", - "timestamp": "2024-09-10T10:26:28.781Z" - }, - "vid": { - "value": "DA-AC-RAC-000001", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "mnmn": { - "value": "Samsung Electronics", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "mnml": { - "value": "http://www.samsung.com", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "mnpv": { - "value": "0G3MPDCKA00010E", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "mnos": { - "value": "TizenRT2.0", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "pi": { - "value": "13549124-3320-4fda-8e5c-3f363e043034", - "timestamp": "2024-09-10T10:26:28.552Z" - }, - "icv": { - "value": "core.1.1.0", - "timestamp": "2024-09-10T10:26:28.552Z" - } - }, - "airConditionerFanMode": { - "fanMode": { - "value": "low", - "timestamp": "2025-02-09T09:14:39.249Z" - }, - "supportedAcFanModes": { - "value": ["auto", "low", "medium", "high", "turbo"], - "timestamp": "2025-02-09T09:14:39.249Z" - }, - "availableAcFanModes": { - "value": null - } - }, - "custom.disabledCapabilities": { - "disabledCapabilities": { - "value": [ - "remoteControlStatus", - "airQualitySensor", - "dustSensor", - "veryFineDustSensor", - "custom.dustFilter", - "custom.deodorFilter", - "custom.deviceReportStateConfiguration", - "samsungce.dongleSoftwareInstallation", - "demandResponseLoadControl", - "custom.airConditionerOdorController" - ], - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "samsungce.driverVersion": { - "versionNumber": { - "value": 24070101, - "timestamp": "2024-09-04T06:35:09.557Z" - } - }, - "fanOscillationMode": { - "supportedFanOscillationModes": { - "value": null, - "timestamp": "2021-04-06T16:43:35.782Z" - }, - "availableFanOscillationModes": { - "value": null - }, - "fanOscillationMode": { - "value": "fixed", - "timestamp": "2025-02-09T09:14:39.249Z" - } - }, - "temperatureMeasurement": { - "temperatureRange": { - "value": null - }, - "temperature": { - "value": 25, - "unit": "C", - "timestamp": "2025-02-09T16:33:29.164Z" - } - }, - "dustSensor": { - "dustLevel": { - "value": null, - "unit": "\u03bcg/m^3", - "timestamp": "2021-04-06T16:43:35.665Z" - }, - "fineDustLevel": { - "value": null, - "unit": "\u03bcg/m^3", - "timestamp": "2021-04-06T16:43:35.665Z" - } - }, - "custom.deviceReportStateConfiguration": { - "reportStateRealtimePeriod": { - "value": null, - "timestamp": "2021-04-06T16:43:35.643Z" - }, - "reportStateRealtime": { - "value": null, - "timestamp": "2021-04-06T16:43:35.643Z" - }, - "reportStatePeriod": { - "value": null, - "timestamp": "2021-04-06T16:43:35.643Z" - } - }, - "thermostatCoolingSetpoint": { - "coolingSetpointRange": { - "value": null - }, - "coolingSetpoint": { - "value": 25, - "unit": "C", - "timestamp": "2025-02-09T09:15:11.608Z" - } - }, - "custom.disabledComponents": { - "disabledComponents": { - "value": ["1"], - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "demandResponseLoadControl": { - "drlcStatus": { - "value": { - "drlcType": 1, - "drlcLevel": -1, - "start": "1970-01-01T00:00:00Z", - "duration": 0, - "override": false - }, - "timestamp": "2024-09-10T10:26:28.781Z" - } - }, - "audioVolume": { - "volume": { - "value": 100, - "unit": "%", - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "powerConsumptionReport": { - "powerConsumption": { - "value": { - "energy": 2247300, - "deltaEnergy": 400, - "power": 0, - "powerEnergy": 0.0, - "persistedEnergy": 2247300, - "energySaved": 0, - "start": "2025-02-09T15:45:29Z", - "end": "2025-02-09T16:15:33Z" - }, - "timestamp": "2025-02-09T16:15:33.639Z" - } - }, - "custom.autoCleaningMode": { - "supportedAutoCleaningModes": { - "value": null - }, - "timedCleanDuration": { - "value": null - }, - "operatingState": { - "value": null - }, - "timedCleanDurationRange": { - "value": null - }, - "supportedOperatingStates": { - "value": null - }, - "progress": { - "value": null - }, - "autoCleaningMode": { - "value": "off", - "timestamp": "2025-02-09T09:14:39.642Z" - } - }, - "refresh": {}, - "execute": { - "data": { - "value": { - "payload": { - "rt": ["oic.r.temperature"], - "if": ["oic.if.baseline", "oic.if.a"], - "range": [16.0, 30.0], - "units": "C", - "temperature": 22.0 - } - }, - "data": { - "href": "/temperature/desired/0" - }, - "timestamp": "2023-07-19T03:07:43.270Z" - } - }, - "samsungce.selfCheck": { - "result": { - "value": null - }, - "supportedActions": { - "value": ["start"], - "timestamp": "2024-09-04T06:35:09.557Z" - }, - "progress": { - "value": null - }, - "errors": { - "value": [], - "timestamp": "2025-02-08T00:44:53.349Z" - }, - "status": { - "value": "ready", - "timestamp": "2025-02-08T00:44:53.549Z" - } - }, - "custom.dustFilter": { - "dustFilterUsageStep": { - "value": null, - "timestamp": "2021-04-06T16:43:35.527Z" - }, - "dustFilterUsage": { - "value": null, - "timestamp": "2021-04-06T16:43:35.527Z" - }, - "dustFilterLastResetDate": { - "value": null, - "timestamp": "2021-04-06T16:43:35.527Z" - }, - "dustFilterStatus": { - "value": null, - "timestamp": "2021-04-06T16:43:35.527Z" - }, - "dustFilterCapacity": { - "value": null, - "timestamp": "2021-04-06T16:43:35.527Z" - }, - "dustFilterResetType": { - "value": null, - "timestamp": "2021-04-06T16:43:35.527Z" - } - }, - "remoteControlStatus": { - "remoteControlEnabled": { - "value": null, - "timestamp": "2021-04-06T16:43:35.379Z" - } - }, - "custom.deodorFilter": { - "deodorFilterCapacity": { - "value": null, - "timestamp": "2021-04-06T16:43:35.502Z" - }, - "deodorFilterLastResetDate": { - "value": null, - "timestamp": "2021-04-06T16:43:35.502Z" - }, - "deodorFilterStatus": { - "value": null, - "timestamp": "2021-04-06T16:43:35.502Z" - }, - "deodorFilterResetType": { - "value": null, - "timestamp": "2021-04-06T16:43:35.502Z" - }, - "deodorFilterUsage": { - "value": null, - "timestamp": "2021-04-06T16:43:35.502Z" - }, - "deodorFilterUsageStep": { - "value": null, - "timestamp": "2021-04-06T16:43:35.502Z" - } - }, - "custom.energyType": { - "energyType": { - "value": "1.0", - "timestamp": "2024-09-10T10:26:28.781Z" - }, - "energySavingSupport": { - "value": false, - "timestamp": "2021-12-29T07:29:17.526Z" - }, - "drMaxDuration": { - "value": null - }, - "energySavingLevel": { - "value": null - }, - "energySavingInfo": { - "value": null - }, - "supportedEnergySavingLevels": { - "value": null - }, - "energySavingOperation": { - "value": null - }, - "notificationTemplateID": { - "value": null - }, - "energySavingOperationSupport": { - "value": null - } - }, - "samsungce.softwareUpdate": { - "targetModule": { - "value": null - }, - "otnDUID": { - "value": "43CEZFTFFL7Z2", - "timestamp": "2025-02-08T00:44:53.855Z" - }, - "lastUpdatedDate": { - "value": null - }, - "availableModules": { - "value": [], - "timestamp": "2025-02-08T00:44:53.855Z" - }, - "newVersionAvailable": { - "value": false, - "timestamp": "2025-02-08T00:44:53.855Z" - }, - "operatingState": { - "value": null - }, - "progress": { - "value": null - } - }, - "veryFineDustSensor": { - "veryFineDustLevel": { - "value": null, - "unit": "\u03bcg/m^3", - "timestamp": "2021-04-06T16:43:35.363Z" - } - } - } - } -} diff --git a/tests/components/smartthings/fixtures/devices/da_ac_rac_000002.json b/tests/components/smartthings/fixtures/devices/da_ac_rac_000002.json deleted file mode 100644 index f143418976015c..00000000000000 --- a/tests/components/smartthings/fixtures/devices/da_ac_rac_000002.json +++ /dev/null @@ -1,303 +0,0 @@ -{ - "items": [ - { - "deviceId": "13549124-3320-4fda-8e5c-3f363e043034", - "name": "[room a/c] Samsung", - "label": "AC Office Granit", - "manufacturerName": "Samsung Electronics", - "presentationId": "DA-AC-RAC-000001", - "deviceManufacturerCode": "Samsung Electronics", - "locationId": "58d3fd7c-c512-4da3-b500-ef269382756c", - "ownerId": "f9a28d7c-1ed5-d9e9-a81c-18971ec081db", - "roomId": "7715151d-0314-457a-a82c-5ce48900e065", - "deviceTypeName": "Samsung OCF Air Conditioner", - "components": [ - { - "id": "main", - "label": "main", - "capabilities": [ - { - "id": "ocf", - "version": 1 - }, - { - "id": "switch", - "version": 1 - }, - { - "id": "airConditionerMode", - "version": 1 - }, - { - "id": "airConditionerFanMode", - "version": 1 - }, - { - "id": "fanOscillationMode", - "version": 1 - }, - { - "id": "airQualitySensor", - "version": 1 - }, - { - "id": "temperatureMeasurement", - "version": 1 - }, - { - "id": "thermostatCoolingSetpoint", - "version": 1 - }, - { - "id": "relativeHumidityMeasurement", - "version": 1 - }, - { - "id": "dustSensor", - "version": 1 - }, - { - "id": "veryFineDustSensor", - "version": 1 - }, - { - "id": "audioVolume", - "version": 1 - }, - { - "id": "remoteControlStatus", - "version": 1 - }, - { - "id": "powerConsumptionReport", - "version": 1 - }, - { - "id": "demandResponseLoadControl", - "version": 1 - }, - { - "id": "refresh", - "version": 1 - }, - { - "id": "execute", - "version": 1 - }, - { - "id": "custom.spiMode", - "version": 1 - }, - { - "id": "custom.thermostatSetpointControl", - "version": 1 - }, - { - "id": "custom.airConditionerOptionalMode", - "version": 1 - }, - { - "id": "custom.airConditionerTropicalNightMode", - "version": 1 - }, - { - "id": "custom.autoCleaningMode", - "version": 1 - }, - { - "id": "custom.deviceReportStateConfiguration", - "version": 1 - }, - { - "id": "custom.energyType", - "version": 1 - }, - { - "id": "custom.dustFilter", - "version": 1 - }, - { - "id": "custom.airConditionerOdorController", - "version": 1 - }, - { - "id": "custom.deodorFilter", - "version": 1 - }, - { - "id": "custom.disabledComponents", - "version": 1 - }, - { - "id": "custom.disabledCapabilities", - "version": 1 - }, - { - "id": "samsungce.deviceIdentification", - "version": 1 - }, - { - "id": "samsungce.dongleSoftwareInstallation", - "version": 1 - }, - { - "id": "samsungce.softwareUpdate", - "version": 1 - }, - { - "id": "samsungce.selfCheck", - "version": 1 - }, - { - "id": "samsungce.driverVersion", - "version": 1 - } - ], - "categories": [ - { - "name": "AirConditioner", - "categoryType": "manufacturer" - } - ] - }, - { - "id": "1", - "label": "1", - "capabilities": [ - { - "id": "switch", - "version": 1 - }, - { - "id": "airConditionerMode", - "version": 1 - }, - { - "id": "airConditionerFanMode", - "version": 1 - }, - { - "id": "fanOscillationMode", - "version": 1 - }, - { - "id": "temperatureMeasurement", - "version": 1 - }, - { - "id": "thermostatCoolingSetpoint", - "version": 1 - }, - { - "id": "relativeHumidityMeasurement", - "version": 1 - }, - { - "id": "airQualitySensor", - "version": 1 - }, - { - "id": "dustSensor", - "version": 1 - }, - { - "id": "veryFineDustSensor", - "version": 1 - }, - { - "id": "odorSensor", - "version": 1 - }, - { - "id": "remoteControlStatus", - "version": 1 - }, - { - "id": "audioVolume", - "version": 1 - }, - { - "id": "custom.thermostatSetpointControl", - "version": 1 - }, - { - "id": "custom.autoCleaningMode", - "version": 1 - }, - { - "id": "custom.airConditionerTropicalNightMode", - "version": 1 - }, - { - "id": "custom.disabledCapabilities", - "version": 1 - }, - { - "id": "ocf", - "version": 1 - }, - { - "id": "powerConsumptionReport", - "version": 1 - }, - { - "id": "demandResponseLoadControl", - "version": 1 - }, - { - "id": "custom.spiMode", - "version": 1 - }, - { - "id": "custom.airConditionerOptionalMode", - "version": 1 - }, - { - "id": "custom.deviceReportStateConfiguration", - "version": 1 - }, - { - "id": "custom.energyType", - "version": 1 - }, - { - "id": "custom.dustFilter", - "version": 1 - }, - { - "id": "custom.airConditionerOdorController", - "version": 1 - }, - { - "id": "custom.deodorFilter", - "version": 1 - } - ], - "categories": [ - { - "name": "Other", - "categoryType": "manufacturer" - } - ] - } - ], - "createTime": "2021-04-06T16:43:34.753Z", - "profile": { - "id": "60fbc713-8da5-315d-b31a-6d6dcde4be7b" - }, - "ocf": { - "ocfDeviceType": "x.com.st.d.sensor.light", - "manufacturerName": "Samsung Electronics", - "vendorId": "VD-Sensor.Light-2023", - "lastSignupTime": "2025-01-08T02:32:04.631093137Z", - "transferCandidate": false, - "additionalAuthCodeRequired": false - }, - "type": "OCF", - "restrictionTier": 0, - "allowed": [], - "executionContext": "CLOUD" - } - ], - "_links": {} -} diff --git a/tests/components/smartthings/snapshots/test_climate.ambr b/tests/components/smartthings/snapshots/test_climate.ambr index 6976371376c3b5..293aa961ca74cb 100644 --- a/tests/components/smartthings/snapshots/test_climate.ambr +++ b/tests/components/smartthings/snapshots/test_climate.ambr @@ -36,7 +36,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': 'bf53a150-f8a4-45d1-aac4-86252475d551_main', 'unit_of_measurement': None, }) @@ -153,10 +153,10 @@ 'max_temp': 35, 'min_temp': 7, 'preset_modes': list([ - 'off', - 'windFree', - 'longWind', - 'speed', + 'none', + 'wind_free', + 'long_wind', + 'boost', 'quiet', 'sleep', ]), @@ -191,7 +191,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': '23c6d296-4656-20d8-f6eb-2ff13e041753_main', 'unit_of_measurement': None, }) @@ -222,12 +222,12 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': 'off', + 'preset_mode': 'none', 'preset_modes': list([ - 'off', - 'windFree', - 'longWind', - 'speed', + 'none', + 'wind_free', + 'long_wind', + 'boost', 'quiet', 'sleep', ]), @@ -341,8 +341,8 @@ 'max_temp': 35, 'min_temp': 7, 'preset_modes': list([ - 'off', - 'windFree', + 'none', + 'wind_free', ]), 'swing_modes': None, }), @@ -370,7 +370,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': '96a5ef74-5832-a84b-f1f7-ca799957065d_main', 'unit_of_measurement': None, }) @@ -402,121 +402,10 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': 'windFree', + 'preset_mode': 'wind_free', 'preset_modes': list([ - 'off', - 'windFree', - ]), - 'supported_features': , - 'swing_mode': 'off', - 'swing_modes': None, - 'temperature': 25, - }), - 'context': , - 'entity_id': 'climate.ac_office_granit', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': 'off', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][climate.ac_office_granit-entry] - EntityRegistryEntrySnapshot({ - 'aliases': set({ - }), - 'area_id': None, - 'capabilities': dict({ - 'fan_modes': list([ - 'auto', - 'low', - 'medium', - 'high', - 'turbo', - ]), - 'hvac_modes': list([ - , - , - , - , - , - , - ]), - 'max_temp': 35, - 'min_temp': 7, - 'preset_modes': list([ - 'off', - 'sleep', - 'quiet', - 'speed', - 'windFree', - 'windFreeSleep', - ]), - 'swing_modes': None, - }), - 'config_entry_id': , - 'config_subentry_id': , - 'device_class': None, - 'device_id': , - 'disabled_by': None, - 'domain': 'climate', - 'entity_category': None, - 'entity_id': 'climate.ac_office_granit', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': None, - 'original_icon': None, - 'original_name': None, - 'platform': 'smartthings', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': , - 'translation_key': None, - 'unique_id': '13549124-3320-4fda-8e5c-3f363e043034_main', - 'unit_of_measurement': None, - }) -# --- -# name: test_all_entities[da_ac_rac_000002][climate.ac_office_granit-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'current_temperature': 25, - 'drlc_status_duration': 0, - 'drlc_status_level': -1, - 'drlc_status_override': False, - 'drlc_status_start': '1970-01-01T00:00:00Z', - 'fan_mode': 'low', - 'fan_modes': list([ - 'auto', - 'low', - 'medium', - 'high', - 'turbo', - ]), - 'friendly_name': 'AC Office Granit', - 'hvac_modes': list([ - , - , - , - , - , - , - ]), - 'max_temp': 35, - 'min_temp': 7, - 'preset_mode': 'windFree', - 'preset_modes': list([ - 'off', - 'sleep', - 'quiet', - 'speed', - 'windFree', - 'windFreeSleep', + 'none', + 'wind_free', ]), 'supported_features': , 'swing_mode': 'off', @@ -554,13 +443,13 @@ 'max_temp': 35, 'min_temp': 7, 'preset_modes': list([ - 'off', + 'none', 'sleep', 'quiet', 'smart', - 'speed', - 'windFree', - 'windFreeSleep', + 'boost', + 'wind_free', + 'wind_free_sleep', ]), 'swing_modes': list([ 'off', @@ -593,7 +482,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': 'c76d6f38-1b7f-13dd-37b5-db18d5272783_main', 'unit_of_measurement': None, }) @@ -622,15 +511,15 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': 'off', + 'preset_mode': 'none', 'preset_modes': list([ - 'off', + 'none', 'sleep', 'quiet', 'smart', - 'speed', - 'windFree', - 'windFreeSleep', + 'boost', + 'wind_free', + 'wind_free_sleep', ]), 'supported_features': , 'swing_mode': 'off', @@ -674,13 +563,13 @@ 'max_temp': 35, 'min_temp': 7, 'preset_modes': list([ - 'off', + 'none', 'sleep', 'quiet', 'smart', - 'speed', - 'windFree', - 'windFreeSleep', + 'boost', + 'wind_free', + 'wind_free_sleep', ]), 'swing_modes': list([ 'off', @@ -713,7 +602,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': '4ece486b-89db-f06a-d54d-748b676b4d8e_main', 'unit_of_measurement': None, }) @@ -745,15 +634,15 @@ ]), 'max_temp': 35, 'min_temp': 7, - 'preset_mode': 'off', + 'preset_mode': 'none', 'preset_modes': list([ - 'off', + 'none', 'sleep', 'quiet', 'smart', - 'speed', - 'windFree', - 'windFreeSleep', + 'boost', + 'wind_free', + 'wind_free_sleep', ]), 'supported_features': , 'swing_mode': 'off', @@ -820,7 +709,7 @@ 'previous_unique_id': None, 'suggested_object_id': None, 'supported_features': , - 'translation_key': None, + 'translation_key': 'air_conditioner', 'unique_id': 'F8042E25-0E53-0000-0000-000000000000_main', 'unit_of_measurement': None, }) diff --git a/tests/components/smartthings/snapshots/test_init.ambr b/tests/components/smartthings/snapshots/test_init.ambr index 0de7bcc5bf0c29..5cd56c316839cc 100644 --- a/tests/components/smartthings/snapshots/test_init.ambr +++ b/tests/components/smartthings/snapshots/test_init.ambr @@ -436,37 +436,6 @@ 'via_device_id': None, }) # --- -# name: test_devices[da_ac_rac_000002] - DeviceRegistryEntrySnapshot({ - 'area_id': 'theater', - 'config_entries': , - 'config_entries_subentries': , - 'configuration_url': 'https://account.smartthings.com', - 'connections': set({ - }), - 'disabled_by': None, - 'entry_type': None, - 'hw_version': None, - 'id': , - 'identifiers': set({ - tuple( - 'smartthings', - '13549124-3320-4fda-8e5c-3f363e043034', - ), - }), - 'labels': set({ - }), - 'manufacturer': 'Samsung Electronics', - 'model': None, - 'model_id': None, - 'name': 'AC Office Granit', - 'name_by_user': None, - 'primary_config_entry': , - 'serial_number': None, - 'sw_version': None, - 'via_device_id': None, - }) -# --- # name: test_devices[da_ac_rac_000003] DeviceRegistryEntrySnapshot({ 'area_id': None, diff --git a/tests/components/smartthings/snapshots/test_sensor.ambr b/tests/components/smartthings/snapshots/test_sensor.ambr index 78c5ba9bed150a..9e83fdacab9103 100644 --- a/tests/components/smartthings/snapshots/test_sensor.ambr +++ b/tests/components/smartthings/snapshots/test_sensor.ambr @@ -2509,446 +2509,6 @@ 'state': '100', }) # --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_energy-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.ac_office_granit_energy', - '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': 'Energy', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '13549124-3320-4fda-8e5c-3f363e043034_main_powerConsumptionReport_powerConsumption_energy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'AC Office Granit Energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '2247.3', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_energy_difference-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.ac_office_granit_energy_difference', - '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': 'Energy difference', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'energy_difference', - 'unique_id': '13549124-3320-4fda-8e5c-3f363e043034_main_powerConsumptionReport_powerConsumption_deltaEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_energy_difference-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'AC Office Granit Energy difference', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_energy_difference', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.4', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_energy_saved-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.ac_office_granit_energy_saved', - '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': 'Energy saved', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'energy_saved', - 'unique_id': '13549124-3320-4fda-8e5c-3f363e043034_main_powerConsumptionReport_powerConsumption_energySaved_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_energy_saved-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'AC Office Granit Energy saved', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_energy_saved', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_humidity-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.ac_office_granit_humidity', - 'has_entity_name': True, - 'hidden_by': None, - 'icon': None, - 'id': , - 'labels': set({ - }), - 'name': None, - 'options': dict({ - }), - 'original_device_class': , - 'original_icon': None, - 'original_name': 'Humidity', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '13549124-3320-4fda-8e5c-3f363e043034_main_relativeHumidityMeasurement_humidity_humidity', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_humidity-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'humidity', - 'friendly_name': 'AC Office Granit Humidity', - 'state_class': , - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_humidity', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '60', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_power-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.ac_office_granit_power', - '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': 'Power', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '13549124-3320-4fda-8e5c-3f363e043034_main_powerConsumptionReport_powerConsumption_power_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_power-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'power', - 'friendly_name': 'AC Office Granit Power', - 'power_consumption_end': '2025-02-09T16:15:33Z', - 'power_consumption_start': '2025-02-09T15:45:29Z', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_power', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_power_energy-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.ac_office_granit_power_energy', - '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': 'Power energy', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'power_energy', - 'unique_id': '13549124-3320-4fda-8e5c-3f363e043034_main_powerConsumptionReport_powerConsumption_powerEnergy_meter', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_power_energy-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'energy', - 'friendly_name': 'AC Office Granit Power energy', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_power_energy', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '0.0', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_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.ac_office_granit_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': 'Temperature', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': None, - 'unique_id': '13549124-3320-4fda-8e5c-3f363e043034_main_temperatureMeasurement_temperature_temperature', - 'unit_of_measurement': , - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_temperature-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'device_class': 'temperature', - 'friendly_name': 'AC Office Granit Temperature', - 'state_class': , - 'unit_of_measurement': , - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_temperature', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '25', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_volume-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.ac_office_granit_volume', - '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': 'Volume', - 'platform': 'smartthings', - 'previous_unique_id': None, - 'suggested_object_id': None, - 'supported_features': 0, - 'translation_key': 'audio_volume', - 'unique_id': '13549124-3320-4fda-8e5c-3f363e043034_main_audioVolume_volume_volume', - 'unit_of_measurement': '%', - }) -# --- -# name: test_all_entities[da_ac_rac_000002][sensor.ac_office_granit_volume-state] - StateSnapshot({ - 'attributes': ReadOnlyDict({ - 'friendly_name': 'AC Office Granit Volume', - 'unit_of_measurement': '%', - }), - 'context': , - 'entity_id': 'sensor.ac_office_granit_volume', - 'last_changed': , - 'last_reported': , - 'last_updated': , - 'state': '100', - }) -# --- # name: test_all_entities[da_ac_rac_000003][sensor.office_airfree_energy-entry] EntityRegistryEntrySnapshot({ 'aliases': set({ diff --git a/tests/components/smartthings/test_climate.py b/tests/components/smartthings/test_climate.py index e1a8129c873b03..d27bd042b119b5 100644 --- a/tests/components/smartthings/test_climate.py +++ b/tests/components/smartthings/test_climate.py @@ -23,6 +23,9 @@ ATTR_TARGET_TEMP_HIGH, ATTR_TARGET_TEMP_LOW, DOMAIN as CLIMATE_DOMAIN, + PRESET_BOOST, + PRESET_NONE, + PRESET_SLEEP, SERVICE_SET_FAN_MODE, SERVICE_SET_HVAC_MODE, SERVICE_SET_PRESET_MODE, @@ -441,85 +444,42 @@ async def test_ac_set_swing_mode( ) -@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000002"]) +@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000003"]) @pytest.mark.parametrize( - "mode", ["off", "sleep", "quiet", "speed", "windFree", "windFreeSleep"] + ("mode", "expected_mode"), + [ + (PRESET_NONE, "off"), + (PRESET_SLEEP, "sleep"), + ("quiet", "quiet"), + (PRESET_BOOST, "speed"), + ("wind_free", "windFree"), + ("wind_free_sleep", "windFreeSleep"), + ], ) async def test_ac_set_preset_mode( hass: HomeAssistant, devices: AsyncMock, mode: str, + expected_mode: str, mock_config_entry: MockConfigEntry, ) -> None: """Test setting and retrieving AC preset modes.""" await setup_integration(hass, mock_config_entry) - # Mock supported preset modes - set_attribute_value( - devices, - Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, - Attribute.SUPPORTED_AC_OPTIONAL_MODE, - ["off", "sleep", "quiet", "speed", "windFree", "windFreeSleep"], - ) - await hass.services.async_call( CLIMATE_DOMAIN, SERVICE_SET_PRESET_MODE, - {ATTR_ENTITY_ID: "climate.ac_office_granit", ATTR_PRESET_MODE: mode}, + {ATTR_ENTITY_ID: "climate.office_airfree", ATTR_PRESET_MODE: mode}, blocking=True, ) devices.execute_device_command.assert_called_with( - "13549124-3320-4fda-8e5c-3f363e043034", + "c76d6f38-1b7f-13dd-37b5-db18d5272783", Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, Command.SET_AC_OPTIONAL_MODE, MAIN, - argument=mode, - ) - - -@pytest.mark.parametrize("device_fixture", ["da_ac_rac_000002"]) -@pytest.mark.parametrize( - "mode", ["off", "sleep", "quiet", "speed", "windFree", "windFreeSleep"] -) -async def test_ac_get_preset_mode( - hass: HomeAssistant, - devices: AsyncMock, - mode: str, - mock_config_entry: MockConfigEntry, -) -> None: - """Test setting and retrieving AC preset modes.""" - await setup_integration(hass, mock_config_entry) - - # Mock supported preset modes - set_attribute_value( - devices, - Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, - Attribute.SUPPORTED_AC_OPTIONAL_MODE, - ["off", "sleep", "quiet", "speed", "windFree", "windFreeSleep"], - ) - - # Mock the current preset mode to simulate the device state - set_attribute_value( - devices, - Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, - Attribute.AC_OPTIONAL_MODE, - mode, + argument=expected_mode, ) - # Trigger an update to refresh the state - await trigger_update( - hass, - devices, - "13549124-3320-4fda-8e5c-3f363e043034", - Capability.CUSTOM_AIR_CONDITIONER_OPTIONAL_MODE, - Attribute.AC_OPTIONAL_MODE, - mode, - ) - - # Verify the preset mode is correctly reflected in the entity state - state = hass.states.get("climate.ac_office_granit") - assert state.attributes[ATTR_PRESET_MODE] == mode - @pytest.mark.parametrize("device_fixture", ["da_ac_rac_000001"]) async def test_ac_state_update( diff --git a/tests/components/synology_dsm/common.py b/tests/components/synology_dsm/common.py index a9d05ce941e126..601f437c107280 100644 --- a/tests/components/synology_dsm/common.py +++ b/tests/components/synology_dsm/common.py @@ -112,6 +112,11 @@ def mock_dsm_storage_disks() -> list[SynoStorageDisk]: return [SynoStorageDisk(**disk_info) for disk_info in disks_data.values()] +def mock_dsm_external_usb_devices_usb0() -> dict[str, SynoCoreExternalUSBDevice]: + """Mock SynologyDSM external USB device with no USB.""" + return {} + + def mock_dsm_external_usb_devices_usb1() -> dict[str, SynoCoreExternalUSBDevice]: """Mock SynologyDSM external USB device with USB Disk 1.""" return { diff --git a/tests/components/synology_dsm/test_sensor.py b/tests/components/synology_dsm/test_sensor.py index a02728dcc4c787..f636dbb79a83c9 100644 --- a/tests/components/synology_dsm/test_sensor.py +++ b/tests/components/synology_dsm/test_sensor.py @@ -18,6 +18,7 @@ from homeassistant.helpers import entity_registry as er from .common import ( + mock_dsm_external_usb_devices_usb0, mock_dsm_external_usb_devices_usb1, mock_dsm_external_usb_devices_usb2, mock_dsm_information, @@ -48,10 +49,10 @@ def mock_dsm_with_usb(): dsm.information = mock_dsm_information() dsm.storage = Mock( get_disk=mock_dsm_storage_get_disk, - disk_temp=Mock(return_value=32), disks_ids=["sata1", "sata2", "sata3"], + disk_temp=Mock(return_value=42), get_volume=mock_dsm_storage_get_volume, - volume_disk_temp_avg=Mock(return_value=32), + volume_disk_temp_avg=Mock(return_value=42), volume_size_used=Mock(return_value=12000138625024), volume_percentage_used=Mock(return_value=38), volumes_ids=["volume_1"], @@ -282,6 +283,72 @@ async def test_external_usb_new_device( assert sensor.attributes[attr_key] == attr_value +async def test_external_usb_availability( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + setup_dsm_with_usb: MagicMock, +) -> None: + """Test Synology DSM USB availability.""" + + expected_sensors_disk_1_available = { + "sensor.nas_meontheinternet_com_usb_disk_1_status": ("normal", {}), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_size": ( + "14901.998046875", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used_space": ( + "5803.1650390625", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used": ( + "38.9", + {}, + ), + } + expected_sensors_disk_1_unavailable = { + "sensor.nas_meontheinternet_com_usb_disk_1_status": ("unavailable", {}), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_size": ( + "unavailable", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used_space": ( + "unavailable", + {}, + ), + "sensor.nas_meontheinternet_com_usb_disk_1_partition_1_partition_used": ( + "unavailable", + {}, + ), + } + + # Initial check of existing sensors + for sensor_id, ( + expected_state, + expected_attrs, + ) in expected_sensors_disk_1_available.items(): + sensor = hass.states.get(sensor_id) + assert sensor is not None + assert sensor.state == expected_state + for attr_key, attr_value in expected_attrs.items(): + assert sensor.attributes[attr_key] == attr_value + + # Mock the get_devices method to simulate no USB devices being connected + setup_dsm_with_usb.external_usb.get_devices = mock_dsm_external_usb_devices_usb0() + # Coordinator refresh + await setup_dsm_with_usb.mock_entry.runtime_data.coordinator_central.async_request_refresh() + await hass.async_block_till_done() + + for sensor_id, ( + expected_state, + expected_attrs, + ) in expected_sensors_disk_1_unavailable.items(): + sensor = hass.states.get(sensor_id) + assert sensor is not None + assert sensor.state == expected_state + for attr_key, attr_value in expected_attrs.items(): + assert sensor.attributes[attr_key] == attr_value + + async def test_no_external_usb( hass: HomeAssistant, setup_dsm_without_usb: MagicMock, diff --git a/tests/components/template/test_analytics.py b/tests/components/template/test_analytics.py deleted file mode 100644 index 33a0373bd170d8..00000000000000 --- a/tests/components/template/test_analytics.py +++ /dev/null @@ -1,105 +0,0 @@ -"""Tests for analytics platform.""" - -import pytest - -from homeassistant.components.analytics import async_devices_payload -from homeassistant.components.template import DOMAIN -from homeassistant.const import Platform -from homeassistant.core import HomeAssistant -from homeassistant.helpers import entity_registry as er -from homeassistant.setup import async_setup_component - - -@pytest.mark.asyncio -async def test_analytics( - hass: HomeAssistant, entity_registry: er.EntityRegistry -) -> None: - """Test the analytics platform.""" - await async_setup_component(hass, "analytics", {}) - - entity_registry.async_get_or_create( - domain=Platform.FAN, - platform="template", - unique_id="fan1", - suggested_object_id="my_fan", - capabilities={"options": ["a", "b", "c"], "preset_modes": ["auto", "eco"]}, - ) - entity_registry.async_get_or_create( - domain=Platform.SELECT, - platform="template", - unique_id="select1", - suggested_object_id="my_select", - capabilities={"not_filtered": "xyz", "options": ["a", "b", "c"]}, - ) - entity_registry.async_get_or_create( - domain=Platform.SELECT, - platform="template", - unique_id="select2", - suggested_object_id="my_select", - capabilities={"not_filtered": "xyz"}, - ) - entity_registry.async_get_or_create( - domain=Platform.LIGHT, - platform="template", - unique_id="light1", - suggested_object_id="my_light", - capabilities={"not_filtered": "abc"}, - ) - - result = await async_devices_payload(hass) - assert result["integrations"][DOMAIN]["entities"] == [ - { - "assumed_state": None, - "capabilities": { - "options": ["a", "b", "c"], - "preset_modes": 2, - }, - "domain": "fan", - "entity_category": None, - "has_entity_name": False, - "modified_by_integration": [ - "capabilities", - ], - "original_device_class": None, - "unit_of_measurement": None, - }, - { - "assumed_state": None, - "capabilities": { - "not_filtered": "xyz", - "options": 3, - }, - "domain": "select", - "entity_category": None, - "has_entity_name": False, - "modified_by_integration": [ - "capabilities", - ], - "original_device_class": None, - "unit_of_measurement": None, - }, - { - "assumed_state": None, - "capabilities": { - "not_filtered": "xyz", - }, - "domain": "select", - "entity_category": None, - "has_entity_name": False, - "modified_by_integration": None, - "original_device_class": None, - "unit_of_measurement": None, - }, - { - "assumed_state": None, - "capabilities": { - "not_filtered": "abc", - }, - "domain": "light", - "entity_category": None, - "has_entity_name": False, - "modified_by_integration": None, - "original_device_class": None, - "unit_of_measurement": None, - }, - ] diff --git a/tests/components/uptimerobot/test_binary_sensor.py b/tests/components/uptimerobot/test_binary_sensor.py index c214a7d15434d7..13e4a556d18e38 100644 --- a/tests/components/uptimerobot/test_binary_sensor.py +++ b/tests/components/uptimerobot/test_binary_sensor.py @@ -16,6 +16,7 @@ from .common import ( MOCK_UPTIMEROBOT_MONITOR, UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY, + mock_uptimerobot_api_response, setup_uptimerobot_integration, ) @@ -49,3 +50,43 @@ async def test_unavailable_on_update_failure(hass: HomeAssistant) -> None: assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) assert entity.state == STATE_UNAVAILABLE + + +async def test_binary_sensor_dynamic(hass: HomeAssistant) -> None: + """Test binary_sensor dynamically added.""" + await setup_uptimerobot_integration(hass) + + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON + + entity_id_2 = "binary_sensor.test_monitor_2" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response( + data=[ + { + "id": 1234, + "friendly_name": "Test monitor", + "status": 2, + "type": 1, + "url": "http://example.com", + }, + { + "id": 5678, + "friendly_name": "Test monitor 2", + "status": 2, + "type": 1, + "url": "http://example2.com", + }, + ] + ), + ): + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert (entity := hass.states.get(UPTIMEROBOT_BINARY_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_ON + + assert (entity := hass.states.get(entity_id_2)) + assert entity.state == STATE_ON diff --git a/tests/components/uptimerobot/test_sensor.py b/tests/components/uptimerobot/test_sensor.py index 15e0b0ba1316cc..26f7432f99cf59 100644 --- a/tests/components/uptimerobot/test_sensor.py +++ b/tests/components/uptimerobot/test_sensor.py @@ -14,6 +14,7 @@ MOCK_UPTIMEROBOT_MONITOR, STATE_UP, UPTIMEROBOT_SENSOR_TEST_ENTITY, + mock_uptimerobot_api_response, setup_uptimerobot_integration, ) @@ -53,3 +54,43 @@ async def test_unavailable_on_update_failure(hass: HomeAssistant) -> None: assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) is not None assert entity.state == STATE_UNAVAILABLE + + +async def test_sensor_dynamic(hass: HomeAssistant) -> None: + """Test sensor dynamically added.""" + await setup_uptimerobot_integration(hass) + + assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_UP + + entity_id_2 = "sensor.test_monitor_2" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response( + data=[ + { + "id": 1234, + "friendly_name": "Test monitor", + "status": 2, + "type": 1, + "url": "http://example.com", + }, + { + "id": 5678, + "friendly_name": "Test monitor 2", + "status": 2, + "type": 1, + "url": "http://example2.com", + }, + ] + ), + ): + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert (entity := hass.states.get(UPTIMEROBOT_SENSOR_TEST_ENTITY)) + assert entity.state == STATE_UP + + assert (entity := hass.states.get(entity_id_2)) + assert entity.state == STATE_UP diff --git a/tests/components/uptimerobot/test_switch.py b/tests/components/uptimerobot/test_switch.py index a88158ea76558f..e42b46db8616c3 100644 --- a/tests/components/uptimerobot/test_switch.py +++ b/tests/components/uptimerobot/test_switch.py @@ -6,6 +6,7 @@ from pyuptimerobot import UptimeRobotAuthenticationException, UptimeRobotException from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN +from homeassistant.components.uptimerobot.const import COORDINATOR_UPDATE_INTERVAL from homeassistant.const import ( ATTR_ENTITY_ID, SERVICE_TURN_OFF, @@ -15,6 +16,7 @@ ) from homeassistant.core import HomeAssistant from homeassistant.exceptions import HomeAssistantError +from homeassistant.util import dt as dt_util from .common import ( MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA, @@ -26,7 +28,7 @@ setup_uptimerobot_integration, ) -from tests.common import MockConfigEntry +from tests.common import MockConfigEntry, async_fire_time_changed async def test_presentation(hass: HomeAssistant) -> None: @@ -71,7 +73,7 @@ async def test_switch_off(hass: HomeAssistant) -> None: async def test_switch_on(hass: HomeAssistant) -> None: - """Test entity unaviable on update failure.""" + """Test entity unavailable on update failure.""" mock_entry = MockConfigEntry(**MOCK_UPTIMEROBOT_CONFIG_ENTRY_DATA) mock_entry.add_to_hass(hass) @@ -180,3 +182,43 @@ async def test_switch_api_failure(hass: HomeAssistant) -> None: assert exc_info.value.translation_placeholders == { "error": "test error from API." } + + +async def test_switch_dynamic(hass: HomeAssistant) -> None: + """Test switch dynamically added.""" + await setup_uptimerobot_integration(hass) + + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) + assert entity.state == STATE_ON + + entity_id_2 = "switch.test_monitor_2" + + with patch( + "pyuptimerobot.UptimeRobot.async_get_monitors", + return_value=mock_uptimerobot_api_response( + data=[ + { + "id": 1234, + "friendly_name": "Test monitor", + "status": 2, + "type": 1, + "url": "http://example.com", + }, + { + "id": 5678, + "friendly_name": "Test monitor 2", + "status": 2, + "type": 1, + "url": "http://example2.com", + }, + ] + ), + ): + async_fire_time_changed(hass, dt_util.utcnow() + COORDINATOR_UPDATE_INTERVAL) + await hass.async_block_till_done() + + assert (entity := hass.states.get(UPTIMEROBOT_SWITCH_TEST_ENTITY)) + assert entity.state == STATE_ON + + assert (entity := hass.states.get(entity_id_2)) + assert entity.state == STATE_ON