diff --git a/.coveragerc b/.coveragerc index 722b6da28d1..a4594a80e6e 100644 --- a/.coveragerc +++ b/.coveragerc @@ -1422,7 +1422,6 @@ omit = homeassistant/components/tensorflow/image_processing.py homeassistant/components/tfiac/climate.py homeassistant/components/thermoworks_smoke/sensor.py - homeassistant/components/thethingsnetwork/* homeassistant/components/thingspeak/* homeassistant/components/thinkingcleaner/* homeassistant/components/thomson/device_tracker.py diff --git a/.strict-typing b/.strict-typing index e31ce0f06f4..313dda48649 100644 --- a/.strict-typing +++ b/.strict-typing @@ -428,6 +428,7 @@ homeassistant.components.tcp.* homeassistant.components.technove.* homeassistant.components.tedee.* homeassistant.components.text.* +homeassistant.components.thethingsnetwork.* homeassistant.components.threshold.* homeassistant.components.tibber.* homeassistant.components.tile.* diff --git a/CODEOWNERS b/CODEOWNERS index a470d0b7502..fd621c03ba2 100644 --- a/CODEOWNERS +++ b/CODEOWNERS @@ -1421,7 +1421,8 @@ build.json @home-assistant/supervisor /tests/components/thermobeacon/ @bdraco /homeassistant/components/thermopro/ @bdraco @h3ss /tests/components/thermopro/ @bdraco @h3ss -/homeassistant/components/thethingsnetwork/ @fabaff +/homeassistant/components/thethingsnetwork/ @angelnu +/tests/components/thethingsnetwork/ @angelnu /homeassistant/components/thread/ @home-assistant/core /tests/components/thread/ @home-assistant/core /homeassistant/components/tibber/ @danielhiversen diff --git a/homeassistant/components/google_generative_ai_conversation/__init__.py b/homeassistant/components/google_generative_ai_conversation/__init__.py index 563d7d341f9..969e6c7a369 100644 --- a/homeassistant/components/google_generative_ai_conversation/__init__.py +++ b/homeassistant/components/google_generative_ai_conversation/__init__.py @@ -2,12 +2,11 @@ from __future__ import annotations -from asyncio import timeout from functools import partial import mimetypes from pathlib import Path -from google.api_core.exceptions import ClientError +from google.api_core.exceptions import ClientError, DeadlineExceeded, GoogleAPICallError import google.generativeai as genai import google.generativeai.types as genai_types import voluptuous as vol @@ -20,11 +19,16 @@ from homeassistant.core import ( ServiceResponse, SupportsResponse, ) -from homeassistant.exceptions import ConfigEntryNotReady, HomeAssistantError +from homeassistant.exceptions import ( + ConfigEntryAuthFailed, + ConfigEntryError, + ConfigEntryNotReady, + HomeAssistantError, +) from homeassistant.helpers import config_validation as cv from homeassistant.helpers.typing import ConfigType -from .const import CONF_PROMPT, DOMAIN, LOGGER +from .const import CONF_CHAT_MODEL, CONF_PROMPT, DOMAIN, RECOMMENDED_CHAT_MODEL SERVICE_GENERATE_CONTENT = "generate_content" CONF_IMAGE_FILENAME = "image_filename" @@ -101,13 +105,19 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: genai.configure(api_key=entry.data[CONF_API_KEY]) try: - async with timeout(5.0): - next(await hass.async_add_executor_job(partial(genai.list_models)), None) - except (ClientError, TimeoutError) as err: + await hass.async_add_executor_job( + partial( + genai.get_model, + entry.options.get(CONF_CHAT_MODEL, RECOMMENDED_CHAT_MODEL), + request_options={"timeout": 5.0}, + ) + ) + except (GoogleAPICallError, ValueError) as err: if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID": - LOGGER.error("Invalid API key: %s", err) - return False - raise ConfigEntryNotReady(err) from err + raise ConfigEntryAuthFailed(err) from err + if isinstance(err, DeadlineExceeded): + raise ConfigEntryNotReady(err) from err + raise ConfigEntryError(err) from err await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) diff --git a/homeassistant/components/google_generative_ai_conversation/config_flow.py b/homeassistant/components/google_generative_ai_conversation/config_flow.py index b559888cc5f..ef700d289c7 100644 --- a/homeassistant/components/google_generative_ai_conversation/config_flow.py +++ b/homeassistant/components/google_generative_ai_conversation/config_flow.py @@ -2,12 +2,13 @@ from __future__ import annotations +from collections.abc import Mapping from functools import partial import logging from types import MappingProxyType from typing import Any -from google.api_core.exceptions import ClientError +from google.api_core.exceptions import ClientError, GoogleAPICallError import google.generativeai as genai import voluptuous as vol @@ -17,7 +18,7 @@ from homeassistant.config_entries import ( ConfigFlowResult, OptionsFlow, ) -from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API +from homeassistant.const import CONF_API_KEY, CONF_LLM_HASS_API, CONF_NAME from homeassistant.core import HomeAssistant from homeassistant.helpers import llm from homeassistant.helpers.selector import ( @@ -54,7 +55,7 @@ from .const import ( _LOGGER = logging.getLogger(__name__) -STEP_USER_DATA_SCHEMA = vol.Schema( +STEP_API_DATA_SCHEMA = vol.Schema( { vol.Required(CONF_API_KEY): str, } @@ -73,7 +74,11 @@ async def validate_input(hass: HomeAssistant, data: dict[str, Any]) -> None: Data has the keys from STEP_USER_DATA_SCHEMA with values provided by the user. """ genai.configure(api_key=data[CONF_API_KEY]) - await hass.async_add_executor_job(partial(genai.list_models)) + + def get_first_model(): + return next(genai.list_models(request_options={"timeout": 5.0}), None) + + await hass.async_add_executor_job(partial(get_first_model)) class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): @@ -81,36 +86,74 @@ class GoogleGenerativeAIConfigFlow(ConfigFlow, domain=DOMAIN): VERSION = 1 + def __init__(self) -> None: + """Initialize a new GoogleGenerativeAIConfigFlow.""" + self.reauth_entry: ConfigEntry | None = None + + async def async_step_api( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Handle the initial step.""" + errors: dict[str, str] = {} + if user_input is not None: + try: + await validate_input(self.hass, user_input) + except GoogleAPICallError as err: + if isinstance(err, ClientError) and err.reason == "API_KEY_INVALID": + errors["base"] = "invalid_auth" + else: + errors["base"] = "cannot_connect" + except Exception: + _LOGGER.exception("Unexpected exception") + errors["base"] = "unknown" + else: + if self.reauth_entry: + return self.async_update_reload_and_abort( + self.reauth_entry, + data=user_input, + ) + return self.async_create_entry( + title="Google Generative AI", + data=user_input, + options=RECOMMENDED_OPTIONS, + ) + return self.async_show_form( + step_id="api", + data_schema=STEP_API_DATA_SCHEMA, + description_placeholders={ + "api_key_url": "https://aistudio.google.com/app/apikey" + }, + errors=errors, + ) + async def async_step_user( self, user_input: dict[str, Any] | None = None ) -> ConfigFlowResult: """Handle the initial step.""" - if user_input is None: - return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA - ) + return await self.async_step_api() - errors = {} - - try: - await validate_input(self.hass, user_input) - except ClientError as err: - if err.reason == "API_KEY_INVALID": - errors["base"] = "invalid_auth" - else: - errors["base"] = "cannot_connect" - except Exception: - _LOGGER.exception("Unexpected exception") - errors["base"] = "unknown" - else: - return self.async_create_entry( - title="Google Generative AI", - data=user_input, - options=RECOMMENDED_OPTIONS, - ) + async def async_step_reauth( + self, entry_data: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle configuration by re-auth.""" + self.reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + return await self.async_step_reauth_confirm() + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is not None: + return await self.async_step_api() + assert self.reauth_entry return self.async_show_form( - step_id="user", data_schema=STEP_USER_DATA_SCHEMA, errors=errors + step_id="reauth_confirm", + description_placeholders={ + CONF_NAME: self.reauth_entry.title, + CONF_API_KEY: self.reauth_entry.data.get(CONF_API_KEY, ""), + }, ) @staticmethod diff --git a/homeassistant/components/google_generative_ai_conversation/conversation.py b/homeassistant/components/google_generative_ai_conversation/conversation.py index f84bd81f80c..ed50ed69a02 100644 --- a/homeassistant/components/google_generative_ai_conversation/conversation.py +++ b/homeassistant/components/google_generative_ai_conversation/conversation.py @@ -221,7 +221,7 @@ class GoogleGenerativeAIConversationEntity( api_prompt = await llm_api.async_get_api_prompt(empty_tool_input) else: - api_prompt = llm.PROMPT_NO_API_CONFIGURED + api_prompt = llm.async_render_no_api_prompt(self.hass) prompt = "\n".join( ( diff --git a/homeassistant/components/google_generative_ai_conversation/strings.json b/homeassistant/components/google_generative_ai_conversation/strings.json index 4c3ed29500c..9fea4805d38 100644 --- a/homeassistant/components/google_generative_ai_conversation/strings.json +++ b/homeassistant/components/google_generative_ai_conversation/strings.json @@ -1,17 +1,24 @@ { "config": { "step": { - "user": { + "api": { "data": { - "api_key": "[%key:common::config_flow::data::api_key%]", - "llm_hass_api": "[%key:common::config_flow::data::llm_hass_api%]" - } + "api_key": "[%key:common::config_flow::data::api_key%]" + }, + "description": "Get your API key from [here]({api_key_url})." + }, + "reauth_confirm": { + "title": "[%key:common::config_flow::title::reauth%]", + "description": "Your current API key: {api_key} is no longer valid. Please enter a new valid API key." } }, "error": { "cannot_connect": "[%key:common::config_flow::error::cannot_connect%]", "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", "unknown": "[%key:common::config_flow::error::unknown%]" + }, + "abort": { + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" } }, "options": { diff --git a/homeassistant/components/hassio/__init__.py b/homeassistant/components/hassio/__init__.py index e4a2bfa4cce..6a084688e99 100644 --- a/homeassistant/components/hassio/__init__.py +++ b/homeassistant/components/hassio/__init__.py @@ -27,10 +27,9 @@ from homeassistant.core import ( HassJob, HomeAssistant, ServiceCall, - async_get_hass, + async_get_hass_or_none, callback, ) -from homeassistant.exceptions import HomeAssistantError from homeassistant.helpers import ( config_validation as cv, device_registry as dr, @@ -160,10 +159,7 @@ VALID_ADDON_SLUG = vol.Match(re.compile(r"^[-_.A-Za-z0-9]+$")) def valid_addon(value: Any) -> str: """Validate value is a valid addon slug.""" value = VALID_ADDON_SLUG(value) - - hass: HomeAssistant | None = None - with suppress(HomeAssistantError): - hass = async_get_hass() + hass = async_get_hass_or_none() if hass and (addons := get_addons_info(hass)) is not None and value not in addons: raise vol.Invalid("Not a valid add-on slug") diff --git a/homeassistant/components/hassio/const.py b/homeassistant/components/hassio/const.py index 0845a98f832..46fa1006c61 100644 --- a/homeassistant/components/hassio/const.py +++ b/homeassistant/components/hassio/const.py @@ -97,10 +97,14 @@ DATA_KEY_CORE = "core" DATA_KEY_HOST = "host" DATA_KEY_SUPERVISOR_ISSUES = "supervisor_issues" +PLACEHOLDER_KEY_ADDON = "addon" +PLACEHOLDER_KEY_ADDON_URL = "addon_url" PLACEHOLDER_KEY_REFERENCE = "reference" PLACEHOLDER_KEY_COMPONENTS = "components" ISSUE_KEY_SYSTEM_DOCKER_CONFIG = "issue_system_docker_config" +ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING = "issue_addon_detached_addon_missing" +ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED = "issue_addon_detached_addon_removed" CORE_CONTAINER = "homeassistant" SUPERVISOR_CONTAINER = "hassio_supervisor" diff --git a/homeassistant/components/hassio/coordinator.py b/homeassistant/components/hassio/coordinator.py index ba3c58d195a..0a5c4dba184 100644 --- a/homeassistant/components/hassio/coordinator.py +++ b/homeassistant/components/hassio/coordinator.py @@ -5,7 +5,7 @@ from __future__ import annotations import asyncio from collections import defaultdict import logging -from typing import Any +from typing import TYPE_CHECKING, Any from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MANUFACTURER, ATTR_NAME @@ -53,7 +53,9 @@ from .const import ( SupervisorEntityModel, ) from .handler import HassIO, HassioAPIError -from .issues import SupervisorIssues + +if TYPE_CHECKING: + from .issues import SupervisorIssues _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/hassio/issues.py b/homeassistant/components/hassio/issues.py index 0bb28a3ceef..2de6f71d838 100644 --- a/homeassistant/components/hassio/issues.py +++ b/homeassistant/components/hassio/issues.py @@ -36,12 +36,17 @@ from .const import ( EVENT_SUPERVISOR_EVENT, EVENT_SUPERVISOR_UPDATE, EVENT_SUPPORTED_CHANGED, + ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, + ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, + PLACEHOLDER_KEY_ADDON, + PLACEHOLDER_KEY_ADDON_URL, PLACEHOLDER_KEY_REFERENCE, REQUEST_REFRESH_DELAY, UPDATE_KEY_SUPERVISOR, SupervisorIssueContext, ) +from .coordinator import get_addons_info from .handler import HassIO, HassioAPIError ISSUE_KEY_UNHEALTHY = "unhealthy" @@ -93,6 +98,8 @@ ISSUE_KEYS_FOR_REPAIRS = { "issue_system_multiple_data_disks", "issue_system_reboot_required", ISSUE_KEY_SYSTEM_DOCKER_CONFIG, + ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING, + ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, } _LOGGER = logging.getLogger(__name__) @@ -258,6 +265,20 @@ class SupervisorIssues: placeholders: dict[str, str] | None = None if issue.reference: placeholders = {PLACEHOLDER_KEY_REFERENCE: issue.reference} + + if issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_MISSING: + addons = get_addons_info(self._hass) + if addons and issue.reference in addons: + placeholders[PLACEHOLDER_KEY_ADDON] = addons[issue.reference][ + "name" + ] + if "url" in addons[issue.reference]: + placeholders[PLACEHOLDER_KEY_ADDON_URL] = addons[ + issue.reference + ]["url"] + else: + placeholders[PLACEHOLDER_KEY_ADDON] = issue.reference + async_create_issue( self._hass, DOMAIN, diff --git a/homeassistant/components/hassio/repairs.py b/homeassistant/components/hassio/repairs.py index cc85be35de5..082dbe38bee 100644 --- a/homeassistant/components/hassio/repairs.py +++ b/homeassistant/components/hassio/repairs.py @@ -14,7 +14,9 @@ from homeassistant.data_entry_flow import FlowResult from . import get_addons_info, get_issues_info from .const import ( + ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED, ISSUE_KEY_SYSTEM_DOCKER_CONFIG, + PLACEHOLDER_KEY_ADDON, PLACEHOLDER_KEY_COMPONENTS, PLACEHOLDER_KEY_REFERENCE, SupervisorIssueContext, @@ -22,12 +24,23 @@ from .const import ( from .handler import async_apply_suggestion from .issues import Issue, Suggestion -SUGGESTION_CONFIRMATION_REQUIRED = {"system_adopt_data_disk", "system_execute_reboot"} +HELP_URLS = { + "help_url": "https://www.home-assistant.io/help/", + "community_url": "https://community.home-assistant.io/", +} + +SUGGESTION_CONFIRMATION_REQUIRED = { + "addon_execute_remove", + "system_adopt_data_disk", + "system_execute_reboot", +} + EXTRA_PLACEHOLDERS = { "issue_mount_mount_failed": { "storage_url": "/config/storage", - } + }, + ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: HELP_URLS, } @@ -168,6 +181,25 @@ class DockerConfigIssueRepairFlow(SupervisorIssueRepairFlow): return placeholders +class DetachedAddonIssueRepairFlow(SupervisorIssueRepairFlow): + """Handler for detached addon issue fixing flows.""" + + @property + def description_placeholders(self) -> dict[str, str] | None: + """Get description placeholders for steps.""" + placeholders: dict[str, str] = super().description_placeholders or {} + if self.issue and self.issue.reference: + addons = get_addons_info(self.hass) + if addons and self.issue.reference in addons: + placeholders[PLACEHOLDER_KEY_ADDON] = addons[self.issue.reference][ + "name" + ] + else: + placeholders[PLACEHOLDER_KEY_ADDON] = self.issue.reference + + return placeholders or None + + async def async_create_fix_flow( hass: HomeAssistant, issue_id: str, @@ -178,5 +210,7 @@ async def async_create_fix_flow( issue = supervisor_issues and supervisor_issues.get_issue(issue_id) if issue and issue.key == ISSUE_KEY_SYSTEM_DOCKER_CONFIG: return DockerConfigIssueRepairFlow(issue_id) + if issue and issue.key == ISSUE_KEY_ADDON_DETACHED_ADDON_REMOVED: + return DetachedAddonIssueRepairFlow(issue_id) return SupervisorIssueRepairFlow(issue_id) diff --git a/homeassistant/components/hassio/strings.json b/homeassistant/components/hassio/strings.json index 6abf9ca6334..04e67d625b3 100644 --- a/homeassistant/components/hassio/strings.json +++ b/homeassistant/components/hassio/strings.json @@ -17,6 +17,23 @@ } }, "issues": { + "issue_addon_detached_addon_missing": { + "title": "Missing repository for an installed add-on", + "description": "Repository for add-on {addon} is missing. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nPlease check the [add-on's documentation]({addon_url}) for installation instructions and add the repository to the store." + }, + "issue_addon_detached_addon_removed": { + "title": "Installed add-on has been removed from repository", + "fix_flow": { + "step": { + "addon_execute_remove": { + "description": "Add-on {addon} has been removed from the repository it was installed from. This means it will not get updates, and backups may not be restored correctly as the supervisor may not be able to build/download the resources required.\n\nClicking submit will uninstall this deprecated add-on. Alternatively, you can check [Home Assistant help]({help_url}) and the [community forum]({community_url}) for alternatives to migrate to." + } + }, + "abort": { + "apply_suggestion_fail": "Could not uninstall the add-on. Check the Supervisor logs for more details." + } + } + }, "issue_mount_mount_failed": { "title": "Network storage device failed", "fix_flow": { diff --git a/homeassistant/components/holiday/manifest.json b/homeassistant/components/holiday/manifest.json index ef8628fb3bf..5ac6611592d 100644 --- a/homeassistant/components/holiday/manifest.json +++ b/homeassistant/components/holiday/manifest.json @@ -5,5 +5,5 @@ "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/holiday", "iot_class": "local_polling", - "requirements": ["holidays==0.48", "babel==2.13.1"] + "requirements": ["holidays==0.49", "babel==2.13.1"] } diff --git a/homeassistant/components/jewish_calendar/__init__.py b/homeassistant/components/jewish_calendar/__init__.py index e1178851e83..bdecaecdcf6 100644 --- a/homeassistant/components/jewish_calendar/__init__.py +++ b/homeassistant/components/jewish_calendar/__init__.py @@ -20,15 +20,17 @@ import homeassistant.helpers.config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.typing import ConfigType -DOMAIN = "jewish_calendar" -CONF_DIASPORA = "diaspora" -CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset" -CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset" -DEFAULT_NAME = "Jewish Calendar" -DEFAULT_CANDLE_LIGHT = 18 -DEFAULT_DIASPORA = False -DEFAULT_HAVDALAH_OFFSET_MINUTES = 0 -DEFAULT_LANGUAGE = "english" +from .const import ( + CONF_CANDLE_LIGHT_MINUTES, + CONF_DIASPORA, + CONF_HAVDALAH_OFFSET_MINUTES, + DEFAULT_CANDLE_LIGHT, + DEFAULT_DIASPORA, + DEFAULT_HAVDALAH_OFFSET_MINUTES, + DEFAULT_LANGUAGE, + DEFAULT_NAME, + DOMAIN, +) PLATFORMS: list[Platform] = [Platform.BINARY_SENSOR, Platform.SENSOR] diff --git a/homeassistant/components/jewish_calendar/binary_sensor.py b/homeassistant/components/jewish_calendar/binary_sensor.py index b01dbc2652e..8516b907749 100644 --- a/homeassistant/components/jewish_calendar/binary_sensor.py +++ b/homeassistant/components/jewish_calendar/binary_sensor.py @@ -22,7 +22,7 @@ from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from . import DEFAULT_NAME, DOMAIN +from .const import DEFAULT_NAME, DOMAIN @dataclass(frozen=True) diff --git a/homeassistant/components/jewish_calendar/config_flow.py b/homeassistant/components/jewish_calendar/config_flow.py index 5632b7cd584..626dc168db8 100644 --- a/homeassistant/components/jewish_calendar/config_flow.py +++ b/homeassistant/components/jewish_calendar/config_flow.py @@ -32,15 +32,17 @@ from homeassistant.helpers.selector import ( ) from homeassistant.helpers.typing import ConfigType -DOMAIN = "jewish_calendar" -CONF_DIASPORA = "diaspora" -CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset" -CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset" -DEFAULT_NAME = "Jewish Calendar" -DEFAULT_CANDLE_LIGHT = 18 -DEFAULT_DIASPORA = False -DEFAULT_HAVDALAH_OFFSET_MINUTES = 0 -DEFAULT_LANGUAGE = "english" +from .const import ( + CONF_CANDLE_LIGHT_MINUTES, + CONF_DIASPORA, + CONF_HAVDALAH_OFFSET_MINUTES, + DEFAULT_CANDLE_LIGHT, + DEFAULT_DIASPORA, + DEFAULT_HAVDALAH_OFFSET_MINUTES, + DEFAULT_LANGUAGE, + DEFAULT_NAME, + DOMAIN, +) LANGUAGE = [ SelectOptionDict(value="hebrew", label="Hebrew"), diff --git a/homeassistant/components/jewish_calendar/const.py b/homeassistant/components/jewish_calendar/const.py new file mode 100644 index 00000000000..4af76a8927b --- /dev/null +++ b/homeassistant/components/jewish_calendar/const.py @@ -0,0 +1,13 @@ +"""Jewish Calendar constants.""" + +DOMAIN = "jewish_calendar" + +CONF_DIASPORA = "diaspora" +CONF_CANDLE_LIGHT_MINUTES = "candle_lighting_minutes_before_sunset" +CONF_HAVDALAH_OFFSET_MINUTES = "havdalah_minutes_after_sunset" + +DEFAULT_NAME = "Jewish Calendar" +DEFAULT_CANDLE_LIGHT = 18 +DEFAULT_DIASPORA = False +DEFAULT_HAVDALAH_OFFSET_MINUTES = 0 +DEFAULT_LANGUAGE = "english" diff --git a/homeassistant/components/jewish_calendar/sensor.py b/homeassistant/components/jewish_calendar/sensor.py index 1616dc589d7..056fabaa805 100644 --- a/homeassistant/components/jewish_calendar/sensor.py +++ b/homeassistant/components/jewish_calendar/sensor.py @@ -22,7 +22,7 @@ from homeassistant.helpers.sun import get_astral_event_date from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType import homeassistant.util.dt as dt_util -from . import DEFAULT_NAME, DOMAIN +from .const import DEFAULT_NAME, DOMAIN _LOGGER = logging.getLogger(__name__) diff --git a/homeassistant/components/mqtt/__init__.py b/homeassistant/components/mqtt/__init__.py index 39e2660ca03..b1130586ec5 100644 --- a/homeassistant/components/mqtt/__init__.py +++ b/homeassistant/components/mqtt/__init__.py @@ -14,7 +14,7 @@ from homeassistant import config as conf_util from homeassistant.components import websocket_api from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DISCOVERY, CONF_PAYLOAD, SERVICE_RELOAD -from homeassistant.core import HassJob, HomeAssistant, ServiceCall, callback +from homeassistant.core import HomeAssistant, ServiceCall, callback from homeassistant.exceptions import ( ConfigValidationError, ServiceValidationError, @@ -72,8 +72,7 @@ from .const import ( # noqa: F401 DEFAULT_QOS, DEFAULT_RETAIN, DOMAIN, - MQTT_CONNECTED, - MQTT_DISCONNECTED, + MQTT_CONNECTION_STATE, RELOADABLE_PLATFORMS, TEMPLATE_ERRORS, ) @@ -475,29 +474,9 @@ def async_subscribe_connection_status( hass: HomeAssistant, connection_status_callback: ConnectionStatusCallback ) -> Callable[[], None]: """Subscribe to MQTT connection changes.""" - connection_status_callback_job = HassJob(connection_status_callback) - - async def connected() -> None: - task = hass.async_run_hass_job(connection_status_callback_job, True) - if task: - await task - - async def disconnected() -> None: - task = hass.async_run_hass_job(connection_status_callback_job, False) - if task: - await task - - subscriptions = { - "connect": async_dispatcher_connect(hass, MQTT_CONNECTED, connected), - "disconnect": async_dispatcher_connect(hass, MQTT_DISCONNECTED, disconnected), - } - - @callback - def unsubscribe() -> None: - subscriptions["connect"]() - subscriptions["disconnect"]() - - return unsubscribe + return async_dispatcher_connect( + hass, MQTT_CONNECTION_STATE, connection_status_callback + ) def is_connected(hass: HomeAssistant) -> bool: diff --git a/homeassistant/components/mqtt/alarm_control_panel.py b/homeassistant/components/mqtt/alarm_control_panel.py index 55d33e2ca41..3de496e4291 100644 --- a/homeassistant/components/mqtt/alarm_control_panel.py +++ b/homeassistant/components/mqtt/alarm_control_panel.py @@ -2,7 +2,6 @@ from __future__ import annotations -from functools import partial import logging import voluptuous as vol @@ -25,7 +24,7 @@ from homeassistant.const import ( STATE_ALARM_PENDING, STATE_ALARM_TRIGGERED, ) -from homeassistant.core import HassJobType, HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -35,8 +34,6 @@ from .config import DEFAULT_RETAIN, MQTT_BASE_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, CONF_SUPPORTED_FEATURES, @@ -203,26 +200,11 @@ class MqttAlarm(MqttEntity, alarm.AlarmControlPanelEntity): return self._attr_state = str(payload) + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._config[CONF_STATE_TOPIC], - "msg_callback": partial( - self._message_callback, - self._state_message_received, - {"_attr_state"}, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - }, + self.add_subscription( + CONF_STATE_TOPIC, self._state_message_received, {"_attr_state"} ) async def _subscribe_topics(self) -> None: diff --git a/homeassistant/components/mqtt/binary_sensor.py b/homeassistant/components/mqtt/binary_sensor.py index f1baaf515f1..2046ca4b11b 100644 --- a/homeassistant/components/mqtt/binary_sensor.py +++ b/homeassistant/components/mqtt/binary_sensor.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import datetime, timedelta -from functools import partial import logging from typing import Any @@ -26,7 +25,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import CALLBACK_TYPE, HassJobType, HomeAssistant, callback +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback import homeassistant.helpers.event as evt @@ -37,7 +36,7 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA -from .const import CONF_ENCODING, CONF_QOS, CONF_STATE_TOPIC, PAYLOAD_NONE +from .const import CONF_STATE_TOPIC, PAYLOAD_NONE from .mixins import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -231,26 +230,11 @@ class MqttBinarySensor(MqttEntity, BinarySensorEntity, RestoreEntity): self.hass, off_delay, self._off_delay_listener ) + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._config[CONF_STATE_TOPIC], - "msg_callback": partial( - self._message_callback, - self._state_message_received, - {"_attr_is_on", "_expired"}, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - }, + self.add_subscription( + CONF_STATE_TOPIC, self._state_message_received, {"_attr_is_on", "_expired"} ) async def _subscribe_topics(self) -> None: diff --git a/homeassistant/components/mqtt/button.py b/homeassistant/components/mqtt/button.py index b5fe2f17f64..8c14a42bbe0 100644 --- a/homeassistant/components/mqtt/button.py +++ b/homeassistant/components/mqtt/button.py @@ -8,7 +8,7 @@ from homeassistant.components import button from homeassistant.components.button import DEVICE_CLASSES_SCHEMA, ButtonEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -73,6 +73,7 @@ class MqttButton(MqttEntity, ButtonEntity): ).async_render self._attr_device_class = self._config.get(CONF_DEVICE_CLASS) + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" diff --git a/homeassistant/components/mqtt/camera.py b/homeassistant/components/mqtt/camera.py index 091db98b95a..3b6e616c1c7 100644 --- a/homeassistant/components/mqtt/camera.py +++ b/homeassistant/components/mqtt/camera.py @@ -3,7 +3,6 @@ from __future__ import annotations from base64 import b64decode -from functools import partial import logging from typing import TYPE_CHECKING @@ -13,14 +12,14 @@ from homeassistant.components import camera from homeassistant.components.camera import Camera from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_QOS, CONF_TOPIC +from .const import CONF_TOPIC from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -107,26 +106,12 @@ class MqttCamera(MqttEntity, Camera): assert isinstance(msg.payload, bytes) self._last_image = msg.payload + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._config[CONF_TOPIC], - "msg_callback": partial( - self._message_callback, - self._image_received, - None, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": None, - "job_type": HassJobType.Callback, - } - }, + self.add_subscription( + CONF_TOPIC, self._image_received, None, disable_encoding=True ) async def _subscribe_topics(self) -> None: diff --git a/homeassistant/components/mqtt/client.py b/homeassistant/components/mqtt/client.py index 50b953c22d8..618389ba121 100644 --- a/homeassistant/components/mqtt/client.py +++ b/homeassistant/components/mqtt/client.py @@ -69,8 +69,7 @@ from .const import ( DEFAULT_WS_HEADERS, DEFAULT_WS_PATH, DOMAIN, - MQTT_CONNECTED, - MQTT_DISCONNECTED, + MQTT_CONNECTION_STATE, PROTOCOL_5, PROTOCOL_31, TRANSPORT_WEBSOCKETS, @@ -1033,7 +1032,7 @@ class MQTT: return self.connected = True - async_dispatcher_send(self.hass, MQTT_CONNECTED) + async_dispatcher_send(self.hass, MQTT_CONNECTION_STATE, True) _LOGGER.debug( "Connected to MQTT server %s:%s (%s)", self.conf[CONF_BROKER], @@ -1229,7 +1228,7 @@ class MQTT: # result is set make sure the first connection result is set self._async_connection_result(False) self.connected = False - async_dispatcher_send(self.hass, MQTT_DISCONNECTED) + async_dispatcher_send(self.hass, MQTT_CONNECTION_STATE, False) _LOGGER.warning( "Disconnected from MQTT server %s:%s (%s)", self.conf[CONF_BROKER], diff --git a/homeassistant/components/mqtt/climate.py b/homeassistant/components/mqtt/climate.py index d0a9175d9fc..0f7358e0326 100644 --- a/homeassistant/components/mqtt/climate.py +++ b/homeassistant/components/mqtt/climate.py @@ -43,7 +43,7 @@ from homeassistant.const import ( PRECISION_WHOLE, UnitOfTemperature, ) -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template @@ -59,7 +59,6 @@ from .const import ( CONF_CURRENT_HUMIDITY_TOPIC, CONF_CURRENT_TEMP_TEMPLATE, CONF_CURRENT_TEMP_TOPIC, - CONF_ENCODING, CONF_MODE_COMMAND_TEMPLATE, CONF_MODE_COMMAND_TOPIC, CONF_MODE_LIST, @@ -68,7 +67,6 @@ from .const import ( CONF_POWER_COMMAND_TEMPLATE, CONF_POWER_COMMAND_TOPIC, CONF_PRECISION, - CONF_QOS, CONF_RETAIN, CONF_TEMP_COMMAND_TEMPLATE, CONF_TEMP_COMMAND_TOPIC, @@ -409,29 +407,6 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): _command_templates: dict[str, Callable[[PublishPayloadType], PublishPayloadType]] _value_templates: dict[str, Callable[[ReceivePayloadType], ReceivePayloadType]] - def add_subscription( - self, - topics: dict[str, dict[str, Any]], - topic: str, - msg_callback: Callable[[ReceiveMessage], None], - tracked_attributes: set[str], - ) -> None: - """Add a subscription.""" - qos: int = self._config[CONF_QOS] - if topic in self._topic and self._topic[topic] is not None: - topics[topic] = { - "topic": self._topic[topic], - "msg_callback": partial( - self._message_callback, - msg_callback, - tracked_attributes, - ), - "entity_id": self.entity_id, - "qos": qos, - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - def render_template( self, msg: ReceiveMessage, template_name: str ) -> ReceivePayloadType: @@ -462,11 +437,9 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): @callback def prepare_subscribe_topics( self, - topics: dict[str, dict[str, Any]], ) -> None: """(Re)Subscribe to topics.""" self.add_subscription( - topics, CONF_CURRENT_TEMP_TOPIC, partial( self.handle_climate_attribute_received, @@ -476,7 +449,6 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): {"_attr_current_temperature"}, ) self.add_subscription( - topics, CONF_TEMP_STATE_TOPIC, partial( self.handle_climate_attribute_received, @@ -486,7 +458,6 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): {"_attr_target_temperature"}, ) self.add_subscription( - topics, CONF_TEMP_LOW_STATE_TOPIC, partial( self.handle_climate_attribute_received, @@ -496,7 +467,6 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): {"_attr_target_temperature_low"}, ) self.add_subscription( - topics, CONF_TEMP_HIGH_STATE_TOPIC, partial( self.handle_climate_attribute_received, @@ -506,10 +476,6 @@ class MqttTemperatureControlEntity(MqttEntity, ABC): {"_attr_target_temperature_high"}, ) - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics - ) - async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" subscription.async_subscribe_topics_internal(self.hass, self._sub_state) @@ -761,16 +727,13 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, dict[str, Any]] = {} - + # add subscriptions for MqttClimate self.add_subscription( - topics, CONF_ACTION_TOPIC, self._handle_action_received, {"_attr_hvac_action"}, ) self.add_subscription( - topics, CONF_CURRENT_HUMIDITY_TOPIC, partial( self.handle_climate_attribute_received, @@ -780,7 +743,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): {"_attr_current_humidity"}, ) self.add_subscription( - topics, CONF_HUMIDITY_STATE_TOPIC, partial( self.handle_climate_attribute_received, @@ -790,7 +752,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): {"_attr_target_humidity"}, ) self.add_subscription( - topics, CONF_MODE_STATE_TOPIC, partial( self._handle_mode_received, @@ -801,7 +762,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): {"_attr_hvac_mode"}, ) self.add_subscription( - topics, CONF_FAN_MODE_STATE_TOPIC, partial( self._handle_mode_received, @@ -812,7 +772,6 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): {"_attr_fan_mode"}, ) self.add_subscription( - topics, CONF_SWING_MODE_STATE_TOPIC, partial( self._handle_mode_received, @@ -823,13 +782,12 @@ class MqttClimate(MqttTemperatureControlEntity, ClimateEntity): {"_attr_swing_mode"}, ) self.add_subscription( - topics, CONF_PRESET_MODE_STATE_TOPIC, self._handle_preset_mode_received, {"_attr_preset_mode"}, ) - - self.prepare_subscribe_topics(topics) + # add subscriptions for MqttTemperatureControlEntity + self.prepare_subscribe_topics() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperatures.""" diff --git a/homeassistant/components/mqtt/const.py b/homeassistant/components/mqtt/const.py index 6a57a7930c3..2d7b4ecf9e2 100644 --- a/homeassistant/components/mqtt/const.py +++ b/homeassistant/components/mqtt/const.py @@ -150,8 +150,7 @@ DEFAULT_WILL = { DOMAIN = "mqtt" -MQTT_CONNECTED = "mqtt_connected" -MQTT_DISCONNECTED = "mqtt_disconnected" +MQTT_CONNECTION_STATE = "mqtt_connection_state" PAYLOAD_EMPTY_JSON = "{}" PAYLOAD_NONE = "None" diff --git a/homeassistant/components/mqtt/cover.py b/homeassistant/components/mqtt/cover.py index c0ee5d4254b..a3bdcf06efa 100644 --- a/homeassistant/components/mqtt/cover.py +++ b/homeassistant/components/mqtt/cover.py @@ -3,7 +3,6 @@ from __future__ import annotations from contextlib import suppress -from functools import partial import logging from typing import Any @@ -28,7 +27,7 @@ from homeassistant.const import ( STATE_OPEN, STATE_OPENING, ) -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.service_info.mqtt import ReceivePayloadType @@ -43,13 +42,11 @@ from . import subscription from .config import MQTT_BASE_SCHEMA from .const import ( CONF_COMMAND_TOPIC, - CONF_ENCODING, CONF_PAYLOAD_CLOSE, CONF_PAYLOAD_OPEN, CONF_PAYLOAD_STOP, CONF_POSITION_CLOSED, CONF_POSITION_OPEN, - CONF_QOS, CONF_RETAIN, CONF_STATE_CLOSED, CONF_STATE_CLOSING, @@ -457,60 +454,29 @@ class MqttCover(MqttEntity, CoverEntity): STATE_CLOSED if self.current_cover_position == 0 else STATE_OPEN ) + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics = {} - - if self._config.get(CONF_GET_POSITION_TOPIC): - topics["get_position_topic"] = { - "topic": self._config.get(CONF_GET_POSITION_TOPIC), - "msg_callback": partial( - self._message_callback, - self._position_message_received, - { - "_attr_current_cover_position", - "_attr_current_cover_tilt_position", - "_attr_is_closed", - "_attr_is_closing", - "_attr_is_opening", - }, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - - if self._config.get(CONF_STATE_TOPIC): - topics["state_topic"] = { - "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": partial( - self._message_callback, - self._state_message_received, - {"_attr_is_closed", "_attr_is_closing", "_attr_is_opening"}, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - - if self._config.get(CONF_TILT_STATUS_TOPIC) is not None: - topics["tilt_status_topic"] = { - "topic": self._config.get(CONF_TILT_STATUS_TOPIC), - "msg_callback": partial( - self._message_callback, - self._tilt_message_received, - {"_attr_current_cover_tilt_position"}, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics + self.add_subscription( + CONF_GET_POSITION_TOPIC, + self._position_message_received, + { + "_attr_current_cover_position", + "_attr_current_cover_tilt_position", + "_attr_is_closed", + "_attr_is_closing", + "_attr_is_opening", + }, + ) + self.add_subscription( + CONF_STATE_TOPIC, + self._state_message_received, + {"_attr_is_closed", "_attr_is_closing", "_attr_is_opening"}, + ) + self.add_subscription( + CONF_TILT_STATUS_TOPIC, + self._tilt_message_received, + {"_attr_current_cover_tilt_position"}, ) async def _subscribe_topics(self) -> None: diff --git a/homeassistant/components/mqtt/device_tracker.py b/homeassistant/components/mqtt/device_tracker.py index 2f6f1be9c42..a45b2adf02c 100644 --- a/homeassistant/components/mqtt/device_tracker.py +++ b/homeassistant/components/mqtt/device_tracker.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial import logging from typing import TYPE_CHECKING @@ -25,14 +24,14 @@ from homeassistant.const import ( STATE_HOME, STATE_NOT_HOME, ) -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_PAYLOAD_RESET, CONF_QOS, CONF_STATE_TOPIC +from .const import CONF_PAYLOAD_RESET, CONF_STATE_TOPIC from .mixins import CONF_JSON_ATTRS_TOPIC, MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage, ReceivePayloadType from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -136,28 +135,11 @@ class MqttDeviceTracker(MqttEntity, TrackerEntity): assert isinstance(msg.payload, str) self._location_name = msg.payload + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - state_topic: str | None = self._config.get(CONF_STATE_TOPIC) - if state_topic is None: - return - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": state_topic, - "msg_callback": partial( - self._message_callback, - self._tracker_message_received, - {"_location_name"}, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "job_type": HassJobType.Callback, - } - }, + self.add_subscription( + CONF_STATE_TOPIC, self._tracker_message_received, {"_location_name"} ) @property diff --git a/homeassistant/components/mqtt/event.py b/homeassistant/components/mqtt/event.py index 6377732cd94..8e30979be78 100644 --- a/homeassistant/components/mqtt/event.py +++ b/homeassistant/components/mqtt/event.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial import logging from typing import Any @@ -17,7 +16,7 @@ from homeassistant.components.event import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -25,13 +24,7 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads_object from . import subscription from .config import MQTT_RO_SCHEMA -from .const import ( - CONF_ENCODING, - CONF_QOS, - CONF_STATE_TOPIC, - PAYLOAD_EMPTY_JSON, - PAYLOAD_NONE, -) +from .const import CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON, PAYLOAD_NONE from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( DATA_MQTT, @@ -186,26 +179,10 @@ class MqttEvent(MqttEntity, EventEntity): mqtt_data = self.hass.data[DATA_MQTT] mqtt_data.state_write_requests.write_state_request(self) + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, dict[str, Any]] = {} - - topics["state_topic"] = { - "topic": self._config[CONF_STATE_TOPIC], - "msg_callback": partial( - self._message_callback, - self._event_received, - None, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics - ) + self.add_subscription(CONF_STATE_TOPIC, self._event_received, None) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" diff --git a/homeassistant/components/mqtt/fan.py b/homeassistant/components/mqtt/fan.py index 7f5c521e9f3..0018c319a0c 100644 --- a/homeassistant/components/mqtt/fan.py +++ b/homeassistant/components/mqtt/fan.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial import logging import math from typing import Any @@ -27,7 +26,7 @@ from homeassistant.const import ( CONF_PAYLOAD_ON, CONF_STATE, ) -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template @@ -43,15 +42,12 @@ from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, ) from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( - MessageCallbackType, MqttCommandTemplate, MqttValueTemplate, PublishPayloadType, @@ -429,52 +425,30 @@ class MqttFan(MqttEntity, FanEntity): return self._attr_current_direction = str(direction) + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, Any] = {} - - def add_subscribe_topic( - topic: str, msg_callback: MessageCallbackType, tracked_attributes: set[str] - ) -> bool: - """Add a topic to subscribe to.""" - if has_topic := self._topic[topic] is not None: - topics[topic] = { - "topic": self._topic[topic], - "msg_callback": partial( - self._message_callback, msg_callback, tracked_attributes - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - return has_topic - - add_subscribe_topic(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"}) - add_subscribe_topic( + self.add_subscription(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"}) + self.add_subscription( CONF_PERCENTAGE_STATE_TOPIC, self._percentage_received, {"_attr_percentage"} ) - add_subscribe_topic( + self.add_subscription( CONF_PRESET_MODE_STATE_TOPIC, self._preset_mode_received, {"_attr_preset_mode"}, ) - if add_subscribe_topic( + if self.add_subscription( CONF_OSCILLATION_STATE_TOPIC, self._oscillation_received, {"_attr_oscillating"}, ): self._attr_oscillating = False - add_subscribe_topic( + self.add_subscription( CONF_DIRECTION_STATE_TOPIC, self._direction_received, {"_attr_current_direction"}, ) - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics - ) - async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" subscription.async_subscribe_topics_internal(self.hass, self._sub_state) diff --git a/homeassistant/components/mqtt/humidifier.py b/homeassistant/components/mqtt/humidifier.py index 6bb4fdb8561..0db2dadd5cf 100644 --- a/homeassistant/components/mqtt/humidifier.py +++ b/homeassistant/components/mqtt/humidifier.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial import logging from typing import Any @@ -30,7 +29,7 @@ from homeassistant.const import ( CONF_PAYLOAD_ON, CONF_STATE, ) -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.template import Template @@ -45,8 +44,6 @@ from .const import ( CONF_COMMAND_TOPIC, CONF_CURRENT_HUMIDITY_TEMPLATE, CONF_CURRENT_HUMIDITY_TOPIC, - CONF_ENCODING, - CONF_QOS, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, @@ -274,27 +271,6 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): for key, tpl in value_templates.items() } - def add_subscription( - self, - topics: dict[str, dict[str, Any]], - topic: str, - msg_callback: Callable[[ReceiveMessage], None], - tracked_attributes: set[str], - ) -> None: - """Add a subscription.""" - qos: int = self._config[CONF_QOS] - if topic in self._topic and self._topic[topic] is not None: - topics[topic] = { - "topic": self._topic[topic], - "msg_callback": partial( - self._message_callback, msg_callback, tracked_attributes - ), - "entity_id": self.entity_id, - "qos": qos, - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - @callback def _state_received(self, msg: ReceiveMessage) -> None: """Handle new received MQTT message.""" @@ -415,34 +391,25 @@ class MqttHumidifier(MqttEntity, HumidifierEntity): self._attr_mode = mode + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, Any] = {} - + self.add_subscription(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"}) self.add_subscription( - topics, CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"} + CONF_ACTION_TOPIC, self._action_received, {"_attr_action"} ) self.add_subscription( - topics, CONF_ACTION_TOPIC, self._action_received, {"_attr_action"} - ) - self.add_subscription( - topics, CONF_CURRENT_HUMIDITY_TOPIC, self._current_humidity_received, {"_attr_current_humidity"}, ) self.add_subscription( - topics, CONF_TARGET_HUMIDITY_STATE_TOPIC, self._target_humidity_received, {"_attr_target_humidity"}, ) self.add_subscription( - topics, CONF_MODE_STATE_TOPIC, self._mode_received, {"_attr_mode"} - ) - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics + CONF_MODE_STATE_TOPIC, self._mode_received, {"_attr_mode"} ) async def _subscribe_topics(self) -> None: diff --git a/homeassistant/components/mqtt/image.py b/homeassistant/components/mqtt/image.py index 4ae7498a8f1..b11b5520174 100644 --- a/homeassistant/components/mqtt/image.py +++ b/homeassistant/components/mqtt/image.py @@ -5,7 +5,6 @@ from __future__ import annotations from base64 import b64decode import binascii from collections.abc import Callable -from functools import partial import logging from typing import TYPE_CHECKING, Any @@ -16,7 +15,7 @@ from homeassistant.components import image from homeassistant.components.image import DEFAULT_CONTENT_TYPE, ImageEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.httpx_client import get_async_client @@ -26,11 +25,9 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import CONF_ENCODING, CONF_QOS from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( DATA_MQTT, - MessageCallbackType, MqttValueTemplate, MqttValueTemplateException, ReceiveMessage, @@ -182,35 +179,14 @@ class MqttImage(MqttEntity, ImageEntity): self._cached_image = None self.hass.data[DATA_MQTT].state_write_requests.write_state_request(self) + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - topics: dict[str, Any] = {} - - def add_subscribe_topic(topic: str, msg_callback: MessageCallbackType) -> bool: - """Add a topic to subscribe to.""" - encoding: str | None - encoding = ( - None - if CONF_IMAGE_TOPIC in self._config - else self._config[CONF_ENCODING] or None - ) - if has_topic := self._topic[topic] is not None: - topics[topic] = { - "topic": self._topic[topic], - "msg_callback": partial(self._message_callback, msg_callback, None), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": encoding, - "job_type": HassJobType.Callback, - } - return has_topic - - add_subscribe_topic(CONF_IMAGE_TOPIC, self._image_data_received) - add_subscribe_topic(CONF_URL_TOPIC, self._image_from_url_request_received) - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics + self.add_subscription( + CONF_IMAGE_TOPIC, self._image_data_received, None, disable_encoding=True + ) + self.add_subscription( + CONF_URL_TOPIC, self._image_from_url_request_received, None ) async def _subscribe_topics(self) -> None: diff --git a/homeassistant/components/mqtt/lawn_mower.py b/homeassistant/components/mqtt/lawn_mower.py index 65d1442c8de..6022ce8afc3 100644 --- a/homeassistant/components/mqtt/lawn_mower.py +++ b/homeassistant/components/mqtt/lawn_mower.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable import contextlib -from functools import partial import logging import voluptuous as vol @@ -17,7 +16,7 @@ from homeassistant.components.lawn_mower import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -25,13 +24,7 @@ from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_BASE_SCHEMA -from .const import ( - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, - DEFAULT_OPTIMISTIC, - DEFAULT_RETAIN, -) +from .const import CONF_RETAIN, DEFAULT_OPTIMISTIC, DEFAULT_RETAIN from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, @@ -172,30 +165,15 @@ class MqttLawnMower(MqttEntity, LawnMowerEntity, RestoreEntity): ) return + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - if self._config.get(CONF_ACTIVITY_STATE_TOPIC) is None: + if not self.add_subscription( + CONF_ACTIVITY_STATE_TOPIC, self._message_received, {"_attr_activity"} + ): # Force into optimistic mode. self._attr_assumed_state = True return - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - CONF_ACTIVITY_STATE_TOPIC: { - "topic": self._config.get(CONF_ACTIVITY_STATE_TOPIC), - "msg_callback": partial( - self._message_callback, - self._message_received, - {"_attr_activity"}, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - }, - ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" diff --git a/homeassistant/components/mqtt/light/schema_basic.py b/homeassistant/components/mqtt/light/schema_basic.py index db6d695b4bb..565cf4d7132 100644 --- a/homeassistant/components/mqtt/light/schema_basic.py +++ b/homeassistant/components/mqtt/light/schema_basic.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial import logging from typing import Any, cast @@ -37,7 +36,7 @@ from homeassistant.const import ( CONF_PAYLOAD_ON, STATE_ON, ) -from homeassistant.core import HassJobType, callback +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType @@ -47,15 +46,12 @@ from .. import subscription from ..config import MQTT_RW_SCHEMA from ..const import ( CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_NONE, ) from ..mixins import MqttEntity from ..models import ( - MessageCallbackType, MqttCommandTemplate, MqttValueTemplate, PayloadSentinel, @@ -562,69 +558,50 @@ class MqttLight(MqttEntity, LightEntity, RestoreEntity): self._attr_color_mode = ColorMode.XY self._attr_xy_color = cast(tuple[float, float], xy_color) + @callback def _prepare_subscribe_topics(self) -> None: # noqa: C901 """(Re)Subscribe to topics.""" - topics: dict[str, dict[str, Any]] = {} - - def add_topic( - topic: str, msg_callback: MessageCallbackType, tracked_attributes: set[str] - ) -> None: - """Add a topic.""" - if self._topic[topic] is not None: - topics[topic] = { - "topic": self._topic[topic], - "msg_callback": partial( - self._message_callback, msg_callback, tracked_attributes - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - - add_topic(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"}) - add_topic( + self.add_subscription(CONF_STATE_TOPIC, self._state_received, {"_attr_is_on"}) + self.add_subscription( CONF_BRIGHTNESS_STATE_TOPIC, self._brightness_received, {"_attr_brightness"} ) - add_topic( + self.add_subscription( CONF_RGB_STATE_TOPIC, self._rgb_received, {"_attr_brightness", "_attr_color_mode", "_attr_rgb_color"}, ) - add_topic( + self.add_subscription( CONF_RGBW_STATE_TOPIC, self._rgbw_received, {"_attr_brightness", "_attr_color_mode", "_attr_rgbw_color"}, ) - add_topic( + self.add_subscription( CONF_RGBWW_STATE_TOPIC, self._rgbww_received, {"_attr_brightness", "_attr_color_mode", "_attr_rgbww_color"}, ) - add_topic( + self.add_subscription( CONF_COLOR_MODE_STATE_TOPIC, self._color_mode_received, {"_attr_color_mode"} ) - add_topic( + self.add_subscription( CONF_COLOR_TEMP_STATE_TOPIC, self._color_temp_received, {"_attr_color_mode", "_attr_color_temp"}, ) - add_topic(CONF_EFFECT_STATE_TOPIC, self._effect_received, {"_attr_effect"}) - add_topic( + self.add_subscription( + CONF_EFFECT_STATE_TOPIC, self._effect_received, {"_attr_effect"} + ) + self.add_subscription( CONF_HS_STATE_TOPIC, self._hs_received, {"_attr_color_mode", "_attr_hs_color"}, ) - add_topic( + self.add_subscription( CONF_XY_STATE_TOPIC, self._xy_received, {"_attr_color_mode", "_attr_xy_color"}, ) - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics - ) - async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" subscription.async_subscribe_topics_internal(self.hass, self._sub_state) diff --git a/homeassistant/components/mqtt/light/schema_json.py b/homeassistant/components/mqtt/light/schema_json.py index 3ec88026e9a..1d3ad3a6ef0 100644 --- a/homeassistant/components/mqtt/light/schema_json.py +++ b/homeassistant/components/mqtt/light/schema_json.py @@ -4,7 +4,6 @@ from __future__ import annotations from collections.abc import Callable from contextlib import suppress -from functools import partial import logging from typing import TYPE_CHECKING, Any, cast @@ -47,7 +46,7 @@ from homeassistant.const import ( CONF_XY, STATE_ON, ) -from homeassistant.core import HassJobType, async_get_hass, callback +from homeassistant.core import async_get_hass, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue from homeassistant.helpers.json import json_dumps @@ -61,7 +60,6 @@ from .. import subscription from ..config import DEFAULT_QOS, DEFAULT_RETAIN, MQTT_RW_SCHEMA from ..const import ( CONF_COMMAND_TOPIC, - CONF_ENCODING, CONF_QOS, CONF_RETAIN, CONF_STATE_TOPIC, @@ -490,40 +488,23 @@ class MqttLightJson(MqttEntity, LightEntity, RestoreEntity): with suppress(KeyError): self._attr_effect = cast(str, values["effect"]) + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - # - if self._topic[CONF_STATE_TOPIC] is None: - return - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, + self.add_subscription( + CONF_STATE_TOPIC, + self._state_received, { - CONF_STATE_TOPIC: { - "topic": self._topic[CONF_STATE_TOPIC], - "msg_callback": partial( - self._message_callback, - self._state_received, - { - "_attr_brightness", - "_attr_color_temp", - "_attr_effect", - "_attr_hs_color", - "_attr_is_on", - "_attr_rgb_color", - "_attr_rgbw_color", - "_attr_rgbww_color", - "_attr_xy_color", - "color_mode", - }, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } + "_attr_brightness", + "_attr_color_temp", + "_attr_effect", + "_attr_hs_color", + "_attr_is_on", + "_attr_rgb_color", + "_attr_rgbw_color", + "_attr_rgbww_color", + "_attr_xy_color", + "color_mode", }, ) diff --git a/homeassistant/components/mqtt/light/schema_template.py b/homeassistant/components/mqtt/light/schema_template.py index cc734253512..d414f219241 100644 --- a/homeassistant/components/mqtt/light/schema_template.py +++ b/homeassistant/components/mqtt/light/schema_template.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial import logging from typing import Any @@ -29,7 +28,7 @@ from homeassistant.const import ( STATE_OFF, STATE_ON, ) -from homeassistant.core import HassJobType, callback +from homeassistant.core import callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.restore_state import RestoreEntity from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -37,13 +36,7 @@ import homeassistant.util.color as color_util from .. import subscription from ..config import MQTT_RW_SCHEMA -from ..const import ( - CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_STATE_TOPIC, - PAYLOAD_NONE, -) +from ..const import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC, PAYLOAD_NONE from ..mixins import MqttEntity from ..models import ( MqttCommandTemplate, @@ -254,35 +247,19 @@ class MqttLightTemplate(MqttEntity, LightEntity, RestoreEntity): else: _LOGGER.warning("Unsupported effect value received") + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - - if self._topics[CONF_STATE_TOPIC] is None: - return - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, + self.add_subscription( + CONF_STATE_TOPIC, + self._state_received, { - "state_topic": { - "topic": self._topics[CONF_STATE_TOPIC], - "msg_callback": partial( - self._message_callback, - self._state_received, - { - "_attr_brightness", - "_attr_color_mode", - "_attr_color_temp", - "_attr_effect", - "_attr_hs_color", - "_attr_is_on", - }, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } + "_attr_brightness", + "_attr_color_mode", + "_attr_color_temp", + "_attr_effect", + "_attr_hs_color", + "_attr_is_on", }, ) diff --git a/homeassistant/components/mqtt/lock.py b/homeassistant/components/mqtt/lock.py index ce0b97e74bf..f4a20d538ae 100644 --- a/homeassistant/components/mqtt/lock.py +++ b/homeassistant/components/mqtt/lock.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial import logging import re from typing import Any @@ -19,7 +18,7 @@ from homeassistant.const import ( CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType, TemplateVarsType @@ -29,9 +28,7 @@ from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, - CONF_ENCODING, CONF_PAYLOAD_RESET, - CONF_QOS, CONF_STATE_OPEN, CONF_STATE_OPENING, CONF_STATE_TOPIC, @@ -203,42 +200,20 @@ class MqttLock(MqttEntity, LockEntity): self._attr_is_unlocking = payload == self._config[CONF_STATE_UNLOCKING] self._attr_is_jammed = payload == self._config[CONF_STATE_JAMMED] + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, dict[str, Any]] - qos: int = self._config[CONF_QOS] - encoding: str | None = self._config[CONF_ENCODING] or None - - if self._config.get(CONF_STATE_TOPIC) is None: - # Force into optimistic mode. - self._optimistic = True - return - topics = { - CONF_STATE_TOPIC: { - "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": partial( - self._message_callback, - self._message_received, - { - "_attr_is_jammed", - "_attr_is_locked", - "_attr_is_locking", - "_attr_is_open", - "_attr_is_opening", - "_attr_is_unlocking", - }, - ), - "entity_id": self.entity_id, - CONF_QOS: qos, - CONF_ENCODING: encoding, - "job_type": HassJobType.Callback, - } - } - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - topics, + self.add_subscription( + CONF_STATE_TOPIC, + self._message_received, + { + "_attr_is_jammed", + "_attr_is_locked", + "_attr_is_locking", + "_attr_is_open", + "_attr_is_opening", + "_attr_is_unlocking", + }, ) async def _subscribe_topics(self) -> None: diff --git a/homeassistant/components/mqtt/mixins.py b/homeassistant/components/mqtt/mixins.py index a89199ed173..713b63ef103 100644 --- a/homeassistant/components/mqtt/mixins.py +++ b/homeassistant/components/mqtt/mixins.py @@ -92,8 +92,7 @@ from .const import ( CONF_VIA_DEVICE, DEFAULT_ENCODING, DOMAIN, - MQTT_CONNECTED, - MQTT_DISCONNECTED, + MQTT_CONNECTION_STATE, ) from .debug_info import log_message from .discovery import ( @@ -460,12 +459,11 @@ class MqttAvailabilityMixin(Entity): await super().async_added_to_hass() self._availability_prepare_subscribe_topics() self._availability_subscribe_topics() - self.async_on_remove( - async_dispatcher_connect(self.hass, MQTT_CONNECTED, self.async_mqtt_connect) - ) self.async_on_remove( async_dispatcher_connect( - self.hass, MQTT_DISCONNECTED, self.async_mqtt_connect + self.hass, + MQTT_CONNECTION_STATE, + self.async_mqtt_connection_state_changed, ) ) @@ -498,10 +496,10 @@ class MqttAvailabilityMixin(Entity): } for avail_topic_conf in self._avail_topics.values(): - avail_topic_conf[CONF_AVAILABILITY_TEMPLATE] = MqttValueTemplate( - avail_topic_conf[CONF_AVAILABILITY_TEMPLATE], - entity=self, - ).async_render_with_possible_json_value + if template := avail_topic_conf[CONF_AVAILABILITY_TEMPLATE]: + avail_topic_conf[CONF_AVAILABILITY_TEMPLATE] = MqttValueTemplate( + template, entity=self + ).async_render_with_possible_json_value self._avail_config = config @@ -537,7 +535,9 @@ class MqttAvailabilityMixin(Entity): """Handle a new received MQTT availability message.""" topic = msg.topic avail_topic = self._avail_topics[topic] - payload = avail_topic[CONF_AVAILABILITY_TEMPLATE](msg.payload) + template = avail_topic[CONF_AVAILABILITY_TEMPLATE] + payload = template(msg.payload) if template else msg.payload + if payload == avail_topic[CONF_PAYLOAD_AVAILABLE]: self._available[topic] = True self._available_latest = True @@ -551,7 +551,7 @@ class MqttAvailabilityMixin(Entity): async_subscribe_topics_internal(self.hass, self._availability_sub_state) @callback - def async_mqtt_connect(self) -> None: + def async_mqtt_connection_state_changed(self, state: bool) -> None: """Update state on connection/disconnection to MQTT broker.""" if not self.hass.is_stopping: self.async_write_ha_state() @@ -1069,6 +1069,7 @@ class MqttEntity( self._attr_unique_id = config.get(CONF_UNIQUE_ID) self._sub_state: dict[str, EntitySubscription] = {} self._discovery = discovery_data is not None + self._subscriptions: dict[str, dict[str, Any]] # Load config self._setup_from_config(self._config) @@ -1095,7 +1096,14 @@ class MqttEntity( async def async_added_to_hass(self) -> None: """Subscribe to MQTT events.""" await super().async_added_to_hass() + self._subscriptions = {} self._prepare_subscribe_topics() + if self._subscriptions: + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, + self._sub_state, + self._subscriptions, + ) await self._subscribe_topics() await self.mqtt_async_added_to_hass() @@ -1120,7 +1128,14 @@ class MqttEntity( self.attributes_prepare_discovery_update(config) self.availability_prepare_discovery_update(config) self.device_info_discovery_update(config) + self._subscriptions = {} self._prepare_subscribe_topics() + if self._subscriptions: + self._sub_state = subscription.async_prepare_subscribe_topics( + self.hass, + self._sub_state, + self._subscriptions, + ) # Finalize MQTT subscriptions await self.attributes_discovery_update(config) @@ -1210,6 +1225,7 @@ class MqttEntity( """(Re)Setup the entity.""" @abstractmethod + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" @@ -1258,6 +1274,35 @@ class MqttEntity( if attributes is not None and self._attrs_have_changed(attrs_snapshot): mqtt_data.state_write_requests.write_state_request(self) + def add_subscription( + self, + state_topic_config_key: str, + msg_callback: Callable[[ReceiveMessage], None], + tracked_attributes: set[str] | None, + disable_encoding: bool = False, + ) -> bool: + """Add a subscription.""" + qos: int = self._config[CONF_QOS] + encoding: str | None = None + if not disable_encoding: + encoding = self._config[CONF_ENCODING] or None + if ( + state_topic_config_key in self._config + and self._config[state_topic_config_key] is not None + ): + self._subscriptions[state_topic_config_key] = { + "topic": self._config[state_topic_config_key], + "msg_callback": partial( + self._message_callback, msg_callback, tracked_attributes + ), + "entity_id": self.entity_id, + "qos": qos, + "encoding": encoding, + "job_type": HassJobType.Callback, + } + return True + return False + def update_device( hass: HomeAssistant, diff --git a/homeassistant/components/mqtt/notify.py b/homeassistant/components/mqtt/notify.py index d3e6bdd3fcb..edc53e572ec 100644 --- a/homeassistant/components/mqtt/notify.py +++ b/homeassistant/components/mqtt/notify.py @@ -8,7 +8,7 @@ from homeassistant.components import notify from homeassistant.components.notify import NotifyEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -68,6 +68,7 @@ class MqttNotify(MqttEntity, NotifyEntity): config.get(CONF_COMMAND_TEMPLATE), entity=self ).async_render + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" diff --git a/homeassistant/components/mqtt/number.py b/homeassistant/components/mqtt/number.py index ededdd14c12..f3d7a432e34 100644 --- a/homeassistant/components/mqtt/number.py +++ b/homeassistant/components/mqtt/number.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial import logging import voluptuous as vol @@ -26,7 +25,7 @@ from homeassistant.const import ( CONF_UNIT_OF_MEASUREMENT, CONF_VALUE_TEMPLATE, ) -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -36,9 +35,7 @@ from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, - CONF_ENCODING, CONF_PAYLOAD_RESET, - CONF_QOS, CONF_STATE_TOPIC, ) from .mixins import MqttEntity, async_setup_entity_entry_helper @@ -193,30 +190,15 @@ class MqttNumber(MqttEntity, RestoreNumber): self._attr_native_value = num_value + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - if self._config.get(CONF_STATE_TOPIC) is None: + if not self.add_subscription( + CONF_STATE_TOPIC, self._message_received, {"_attr_native_value"} + ): # Force into optimistic mode. self._attr_assumed_state = True return - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": partial( - self._message_callback, - self._message_received, - {"_attr_native_value"}, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - }, - ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" diff --git a/homeassistant/components/mqtt/scene.py b/homeassistant/components/mqtt/scene.py index 4381a4ea9a3..c51166ce457 100644 --- a/homeassistant/components/mqtt/scene.py +++ b/homeassistant/components/mqtt/scene.py @@ -10,7 +10,7 @@ from homeassistant.components import scene from homeassistant.components.scene import Scene from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_PAYLOAD_ON -from homeassistant.core import HomeAssistant +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -72,6 +72,7 @@ class MqttScene( def _setup_from_config(self, config: ConfigType) -> None: """(Re)Setup the entity.""" + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" diff --git a/homeassistant/components/mqtt/select.py b/homeassistant/components/mqtt/select.py index 6526161d2de..0adc3344ed3 100644 --- a/homeassistant/components/mqtt/select.py +++ b/homeassistant/components/mqtt/select.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial import logging import voluptuous as vol @@ -12,7 +11,7 @@ from homeassistant.components import select from homeassistant.components.select import SelectEntity from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_NAME, CONF_OPTIMISTIC, CONF_VALUE_TEMPLATE -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -20,13 +19,7 @@ from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA -from .const import ( - CONF_COMMAND_TEMPLATE, - CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_STATE_TOPIC, -) +from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_STATE_TOPIC from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( MqttCommandTemplate, @@ -133,30 +126,15 @@ class MqttSelect(MqttEntity, SelectEntity, RestoreEntity): return self._attr_current_option = payload + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - if self._config.get(CONF_STATE_TOPIC) is None: + if not self.add_subscription( + CONF_STATE_TOPIC, self._message_received, {"_attr_current_option"} + ): # Force into optimistic mode. self._attr_assumed_state = True return - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - "state_topic": { - "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": partial( - self._message_callback, - self._message_received, - {"_attr_current_option"}, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - }, - ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" diff --git a/homeassistant/components/mqtt/sensor.py b/homeassistant/components/mqtt/sensor.py index fc6b6dcf273..578c912e7b2 100644 --- a/homeassistant/components/mqtt/sensor.py +++ b/homeassistant/components/mqtt/sensor.py @@ -4,9 +4,7 @@ from __future__ import annotations from collections.abc import Callable from datetime import datetime, timedelta -from functools import partial import logging -from typing import Any import voluptuous as vol @@ -31,13 +29,7 @@ from homeassistant.const import ( STATE_UNAVAILABLE, STATE_UNKNOWN, ) -from homeassistant.core import ( - CALLBACK_TYPE, - HassJobType, - HomeAssistant, - State, - callback, -) +from homeassistant.core import CALLBACK_TYPE, HomeAssistant, State, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.event import async_call_later @@ -46,7 +38,7 @@ from homeassistant.util import dt as dt_util from . import subscription from .config import MQTT_RO_SCHEMA -from .const import CONF_ENCODING, CONF_QOS, CONF_STATE_TOPIC, PAYLOAD_NONE +from .const import CONF_STATE_TOPIC, PAYLOAD_NONE from .mixins import MqttAvailabilityMixin, MqttEntity, async_setup_entity_entry_helper from .models import ( MqttValueTemplate, @@ -289,25 +281,13 @@ class MqttSensor(MqttEntity, RestoreSensor): if CONF_LAST_RESET_VALUE_TEMPLATE in self._config: _update_last_reset(msg) + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, dict[str, Any]] = {} - - topics["state_topic"] = { - "topic": self._config[CONF_STATE_TOPIC], - "msg_callback": partial( - self._message_callback, - self._state_message_received, - {"_attr_native_value", "_attr_last_reset", "_expired"}, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics + self.add_subscription( + CONF_STATE_TOPIC, + self._state_message_received, + {"_attr_native_value", "_attr_last_reset", "_expired"}, ) async def _subscribe_topics(self) -> None: diff --git a/homeassistant/components/mqtt/siren.py b/homeassistant/components/mqtt/siren.py index 09fd5db2684..5b5835d41d3 100644 --- a/homeassistant/components/mqtt/siren.py +++ b/homeassistant/components/mqtt/siren.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial import logging from typing import Any, cast @@ -28,7 +27,7 @@ from homeassistant.const import ( CONF_PAYLOAD_OFF, CONF_PAYLOAD_ON, ) -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.json import json_dumps @@ -41,8 +40,6 @@ from .config import MQTT_RW_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, CONF_STATE_TOPIC, CONF_STATE_VALUE_TEMPLATE, PAYLOAD_EMPTY_JSON, @@ -261,30 +258,17 @@ class MqttSiren(MqttEntity, SirenEntity): self._extra_attributes = dict(self._extra_attributes) self._update(process_turn_on_params(self, params)) + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - if self._config.get(CONF_STATE_TOPIC) is None: + if not self.add_subscription( + CONF_STATE_TOPIC, + self._state_message_received, + {"_attr_is_on", "_extra_attributes"}, + ): # Force into optimistic mode. self._optimistic = True return - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - CONF_STATE_TOPIC: { - "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": partial( - self._message_callback, - self._state_message_received, - {"_attr_is_on", "_extra_attributes"}, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - }, - ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" diff --git a/homeassistant/components/mqtt/switch.py b/homeassistant/components/mqtt/switch.py index f66a7a80d3d..fb33c16fd74 100644 --- a/homeassistant/components/mqtt/switch.py +++ b/homeassistant/components/mqtt/switch.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial from typing import Any import voluptuous as vol @@ -20,7 +19,7 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, STATE_ON, ) -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -29,13 +28,7 @@ from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA -from .const import ( - CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_STATE_TOPIC, - PAYLOAD_NONE, -) +from .const import CONF_COMMAND_TOPIC, CONF_STATE_TOPIC, PAYLOAD_NONE from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA @@ -124,30 +117,15 @@ class MqttSwitch(MqttEntity, SwitchEntity, RestoreEntity): elif payload == PAYLOAD_NONE: self._attr_is_on = None + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - if self._config.get(CONF_STATE_TOPIC) is None: + if not self.add_subscription( + CONF_STATE_TOPIC, self._state_message_received, {"_attr_is_on"} + ): # Force into optimistic mode. self._optimistic = True return - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, - self._sub_state, - { - CONF_STATE_TOPIC: { - "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": partial( - self._message_callback, - self._state_message_received, - {"_attr_is_on"}, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - }, - ) async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" diff --git a/homeassistant/components/mqtt/text.py b/homeassistant/components/mqtt/text.py index cc688403a5a..ab79edd3150 100644 --- a/homeassistant/components/mqtt/text.py +++ b/homeassistant/components/mqtt/text.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial import logging import re from typing import Any @@ -20,23 +19,16 @@ from homeassistant.const import ( CONF_VALUE_TEMPLATE, MAX_LENGTH_STATE_STATE, ) -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType from . import subscription from .config import MQTT_RW_SCHEMA -from .const import ( - CONF_COMMAND_TEMPLATE, - CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_STATE_TOPIC, -) +from .const import CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, CONF_STATE_TOPIC from .mixins import MqttEntity, async_setup_entity_entry_helper from .models import ( - MessageCallbackType, MqttCommandTemplate, MqttValueTemplate, PublishPayloadType, @@ -163,39 +155,15 @@ class MqttTextEntity(MqttEntity, TextEntity): return self._attr_native_value = payload + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, Any] = {} - - def add_subscription( - topics: dict[str, Any], - topic: str, - msg_callback: MessageCallbackType, - tracked_attributes: set[str], - ) -> None: - if self._config.get(topic) is not None: - topics[topic] = { - "topic": self._config[topic], - "msg_callback": partial( - self._message_callback, msg_callback, tracked_attributes - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - - add_subscription( - topics, + self.add_subscription( CONF_STATE_TOPIC, self._handle_state_message_received, {"_attr_native_value"}, ) - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics - ) - async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" subscription.async_subscribe_topics_internal(self.hass, self._sub_state) diff --git a/homeassistant/components/mqtt/update.py b/homeassistant/components/mqtt/update.py index d9d8c961ae8..74d271eb95e 100644 --- a/homeassistant/components/mqtt/update.py +++ b/homeassistant/components/mqtt/update.py @@ -2,7 +2,6 @@ from __future__ import annotations -from functools import partial import logging from typing import Any, TypedDict, cast @@ -16,7 +15,7 @@ from homeassistant.components.update import ( ) from homeassistant.config_entries import ConfigEntry from homeassistant.const import CONF_DEVICE_CLASS, CONF_NAME, CONF_VALUE_TEMPLATE -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback from homeassistant.helpers import config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.restore_state import RestoreEntity @@ -25,16 +24,9 @@ from homeassistant.util.json import JSON_DECODE_EXCEPTIONS, json_loads from . import subscription from .config import DEFAULT_RETAIN, MQTT_RO_SCHEMA -from .const import ( - CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, - CONF_RETAIN, - CONF_STATE_TOPIC, - PAYLOAD_EMPTY_JSON, -) +from .const import CONF_COMMAND_TOPIC, CONF_RETAIN, CONF_STATE_TOPIC, PAYLOAD_EMPTY_JSON from .mixins import MqttEntity, async_setup_entity_entry_helper -from .models import MessageCallbackType, MqttValueTemplate, ReceiveMessage +from .models import MqttValueTemplate, ReceiveMessage from .schemas import MQTT_ENTITY_COMMON_SCHEMA from .util import valid_publish_topic, valid_subscribe_topic @@ -210,30 +202,10 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): if isinstance(latest_version, str) and latest_version != "": self._attr_latest_version = latest_version + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, Any] = {} - - def add_subscription( - topics: dict[str, Any], - topic: str, - msg_callback: MessageCallbackType, - tracked_attributes: set[str], - ) -> None: - if self._config.get(topic) is not None: - topics[topic] = { - "topic": self._config[topic], - "msg_callback": partial( - self._message_callback, msg_callback, tracked_attributes - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - - add_subscription( - topics, + self.add_subscription( CONF_STATE_TOPIC, self._handle_state_message_received, { @@ -245,17 +217,12 @@ class MqttUpdate(MqttEntity, UpdateEntity, RestoreEntity): "_entity_picture", }, ) - add_subscription( - topics, + self.add_subscription( CONF_LATEST_VERSION_TOPIC, self._handle_latest_version_received, {"_attr_latest_version"}, ) - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics - ) - async def _subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" subscription.async_subscribe_topics_internal(self.hass, self._sub_state) diff --git a/homeassistant/components/mqtt/util.py b/homeassistant/components/mqtt/util.py index 3611b809c46..eeca2361305 100644 --- a/homeassistant/components/mqtt/util.py +++ b/homeassistant/components/mqtt/util.py @@ -114,8 +114,6 @@ async def async_wait_for_mqtt_client(hass: HomeAssistant) -> bool: hass.data[DATA_MQTT_AVAILABLE] = state_reached_future else: state_reached_future = hass.data[DATA_MQTT_AVAILABLE] - if state_reached_future.done(): - return state_reached_future.result() try: async with asyncio.timeout(AVAILABILITY_TIMEOUT): diff --git a/homeassistant/components/mqtt/vacuum.py b/homeassistant/components/mqtt/vacuum.py index b750fdcb49c..0b48b7a68ef 100644 --- a/homeassistant/components/mqtt/vacuum.py +++ b/homeassistant/components/mqtt/vacuum.py @@ -8,7 +8,6 @@ from __future__ import annotations from collections.abc import Callable -from functools import partial import logging from typing import Any, cast @@ -31,7 +30,7 @@ from homeassistant.const import ( STATE_IDLE, STATE_PAUSED, ) -from homeassistant.core import HassJobType, HomeAssistant, async_get_hass, callback +from homeassistant.core import HomeAssistant, async_get_hass, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.issue_registry import IssueSeverity, async_create_issue @@ -43,8 +42,6 @@ from . import subscription from .config import MQTT_BASE_SCHEMA from .const import ( CONF_COMMAND_TOPIC, - CONF_ENCODING, - CONF_QOS, CONF_RETAIN, CONF_SCHEMA, CONF_STATE_TOPIC, @@ -331,25 +328,13 @@ class MqttStateVacuum(MqttEntity, StateVacuumEntity): del payload[STATE] self._update_state_attributes(payload) + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, Any] = {} - - if state_topic := self._config.get(CONF_STATE_TOPIC): - topics["state_position_topic"] = { - "topic": state_topic, - "msg_callback": partial( - self._message_callback, - self._state_message_received, - {"_attr_battery_level", "_attr_fan_speed", "_attr_state"}, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics + self.add_subscription( + CONF_STATE_TOPIC, + self._state_message_received, + {"_attr_battery_level", "_attr_fan_speed", "_attr_state"}, ) async def _subscribe_topics(self) -> None: diff --git a/homeassistant/components/mqtt/valve.py b/homeassistant/components/mqtt/valve.py index 154680cf14a..33b2c81499c 100644 --- a/homeassistant/components/mqtt/valve.py +++ b/homeassistant/components/mqtt/valve.py @@ -3,7 +3,6 @@ from __future__ import annotations from contextlib import suppress -from functools import partial import logging from typing import Any @@ -26,7 +25,7 @@ from homeassistant.const import ( STATE_OPEN, STATE_OPENING, ) -from homeassistant.core import HassJobType, HomeAssistant, callback +from homeassistant.core import HomeAssistant, callback import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback from homeassistant.helpers.typing import ConfigType @@ -41,13 +40,11 @@ from .config import MQTT_BASE_SCHEMA from .const import ( CONF_COMMAND_TEMPLATE, CONF_COMMAND_TOPIC, - CONF_ENCODING, CONF_PAYLOAD_CLOSE, CONF_PAYLOAD_OPEN, CONF_PAYLOAD_STOP, CONF_POSITION_CLOSED, CONF_POSITION_OPEN, - CONF_QOS, CONF_RETAIN, CONF_STATE_CLOSED, CONF_STATE_CLOSING, @@ -337,31 +334,18 @@ class MqttValve(MqttEntity, ValveEntity): else: self._process_binary_valve_update(msg, state_payload) + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics = {} - - if self._config.get(CONF_STATE_TOPIC): - topics["state_topic"] = { - "topic": self._config.get(CONF_STATE_TOPIC), - "msg_callback": partial( - self._message_callback, - self._state_message_received, - { - "_attr_current_valve_position", - "_attr_is_closed", - "_attr_is_closing", - "_attr_is_opening", - }, - ), - "entity_id": self.entity_id, - "qos": self._config[CONF_QOS], - "encoding": self._config[CONF_ENCODING] or None, - "job_type": HassJobType.Callback, - } - - self._sub_state = subscription.async_prepare_subscribe_topics( - self.hass, self._sub_state, topics + self.add_subscription( + CONF_STATE_TOPIC, + self._state_message_received, + { + "_attr_current_valve_position", + "_attr_is_closed", + "_attr_is_closing", + "_attr_is_opening", + }, ) async def _subscribe_topics(self) -> None: diff --git a/homeassistant/components/mqtt/water_heater.py b/homeassistant/components/mqtt/water_heater.py index 07d94429854..75e2373b01b 100644 --- a/homeassistant/components/mqtt/water_heater.py +++ b/homeassistant/components/mqtt/water_heater.py @@ -281,18 +281,17 @@ class MqttWaterHeater(MqttTemperatureControlEntity, WaterHeaterEntity): assert isinstance(payload, str) self._attr_current_operation = payload + @callback def _prepare_subscribe_topics(self) -> None: """(Re)Subscribe to topics.""" - topics: dict[str, dict[str, Any]] = {} - + # add subscriptions for WaterHeaterEntity self.add_subscription( - topics, CONF_MODE_STATE_TOPIC, self._handle_current_mode_received, {"_attr_current_operation"}, ) - - self.prepare_subscribe_topics(topics) + # add subscriptions for MqttTemperatureControlEntity + self.prepare_subscribe_topics() async def async_set_temperature(self, **kwargs: Any) -> None: """Set new target temperature.""" diff --git a/homeassistant/components/number/__init__.py b/homeassistant/components/number/__init__.py index e5b307f5e57..77dde242b7e 100644 --- a/homeassistant/components/number/__init__.py +++ b/homeassistant/components/number/__init__.py @@ -15,8 +15,13 @@ import voluptuous as vol from homeassistant.config_entries import ConfigEntry from homeassistant.const import ATTR_MODE, CONF_UNIT_OF_MEASUREMENT, UnitOfTemperature -from homeassistant.core import HomeAssistant, ServiceCall, async_get_hass, callback -from homeassistant.exceptions import HomeAssistantError, ServiceValidationError +from homeassistant.core import ( + HomeAssistant, + ServiceCall, + async_get_hass_or_none, + callback, +) +from homeassistant.exceptions import ServiceValidationError from homeassistant.helpers.config_validation import ( PLATFORM_SCHEMA, PLATFORM_SCHEMA_BASE, @@ -213,10 +218,9 @@ class NumberEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): "value", ) ): - hass: HomeAssistant | None = None - with suppress(HomeAssistantError): - hass = async_get_hass() - report_issue = async_suggest_report_issue(hass, module=cls.__module__) + report_issue = async_suggest_report_issue( + async_get_hass_or_none(), module=cls.__module__ + ) _LOGGER.warning( ( "%s::%s is overriding deprecated methods on an instance of " diff --git a/homeassistant/components/openai_conversation/conversation.py b/homeassistant/components/openai_conversation/conversation.py index be3b8ea9126..eb2f0911a20 100644 --- a/homeassistant/components/openai_conversation/conversation.py +++ b/homeassistant/components/openai_conversation/conversation.py @@ -138,7 +138,7 @@ class OpenAIConversationEntity( api_prompt = await llm_api.async_get_api_prompt(empty_tool_input) else: - api_prompt = llm.PROMPT_NO_API_CONFIGURED + api_prompt = llm.async_render_no_api_prompt(self.hass) prompt = "\n".join( ( diff --git a/homeassistant/components/sensor/__init__.py b/homeassistant/components/sensor/__init__.py index ffe324fc8c4..7e7eaf8aef2 100644 --- a/homeassistant/components/sensor/__init__.py +++ b/homeassistant/components/sensor/__init__.py @@ -787,10 +787,10 @@ class SensorEntity(Entity, cached_properties=CACHED_PROPERTIES_WITH_ATTR_): display_precision = max(0, display_precision + ratio_log) sensor_options: Mapping[str, Any] = self.registry_entry.options.get(DOMAIN, {}) - if ( - "suggested_display_precision" in sensor_options - and sensor_options["suggested_display_precision"] == display_precision - ): + if "suggested_display_precision" not in sensor_options: + if display_precision is None: + return + elif sensor_options["suggested_display_precision"] == display_precision: return registry = er.async_get(self.hass) diff --git a/homeassistant/components/thethingsnetwork/__init__.py b/homeassistant/components/thethingsnetwork/__init__.py index 32850d05e57..253ce7a052e 100644 --- a/homeassistant/components/thethingsnetwork/__init__.py +++ b/homeassistant/components/thethingsnetwork/__init__.py @@ -1,29 +1,28 @@ """Support for The Things network.""" +import logging + import voluptuous as vol +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_HOST from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir import homeassistant.helpers.config_validation as cv from homeassistant.helpers.typing import ConfigType -CONF_ACCESS_KEY = "access_key" -CONF_APP_ID = "app_id" +from .const import CONF_APP_ID, DOMAIN, PLATFORMS, TTN_API_HOST +from .coordinator import TTNCoordinator -DATA_TTN = "data_thethingsnetwork" -DOMAIN = "thethingsnetwork" - -TTN_ACCESS_KEY = "ttn_access_key" -TTN_APP_ID = "ttn_app_id" -TTN_DATA_STORAGE_URL = ( - "https://{app_id}.data.thethingsnetwork.org/{endpoint}/{device_id}" -) +_LOGGER = logging.getLogger(__name__) CONFIG_SCHEMA = vol.Schema( { + # Configuration via yaml not longer supported - keeping to warn about migration DOMAIN: vol.Schema( { vol.Required(CONF_APP_ID): cv.string, - vol.Required(CONF_ACCESS_KEY): cv.string, + vol.Required("access_key"): cv.string, } ) }, @@ -33,10 +32,57 @@ CONFIG_SCHEMA = vol.Schema( async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: """Initialize of The Things Network component.""" - conf = config[DOMAIN] - app_id = conf.get(CONF_APP_ID) - access_key = conf.get(CONF_ACCESS_KEY) - hass.data[DATA_TTN] = {TTN_ACCESS_KEY: access_key, TTN_APP_ID: app_id} + if DOMAIN in config: + ir.async_create_issue( + hass, + DOMAIN, + "manual_migration", + breaks_in_ha_version="2024.12.0", + is_fixable=False, + severity=ir.IssueSeverity.ERROR, + translation_key="manual_migration", + translation_placeholders={ + "domain": DOMAIN, + "v2_v3_migration_url": "https://www.thethingsnetwork.org/forum/c/v2-to-v3-upgrade/102", + "v2_deprecation_url": "https://www.thethingsnetwork.org/forum/t/the-things-network-v2-is-permanently-shutting-down-completed/50710", + }, + ) return True + + +async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Establish connection with The Things Network.""" + + _LOGGER.debug( + "Set up %s at %s", + entry.data[CONF_API_KEY], + entry.data.get(CONF_HOST, TTN_API_HOST), + ) + + coordinator = TTNCoordinator(hass, entry) + + await coordinator.async_config_entry_first_refresh() + + hass.data.setdefault(DOMAIN, {})[entry.entry_id] = coordinator + + await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS) + + return True + + +async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: + """Unload a config entry.""" + + _LOGGER.debug( + "Remove %s at %s", + entry.data[CONF_API_KEY], + entry.data.get(CONF_HOST, TTN_API_HOST), + ) + + # Unload entities created for each supported platform + unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS) + if unload_ok: + del hass.data[DOMAIN][entry.entry_id] + return True diff --git a/homeassistant/components/thethingsnetwork/config_flow.py b/homeassistant/components/thethingsnetwork/config_flow.py new file mode 100644 index 00000000000..cbb780e7064 --- /dev/null +++ b/homeassistant/components/thethingsnetwork/config_flow.py @@ -0,0 +1,108 @@ +"""The Things Network's integration config flow.""" + +from collections.abc import Mapping +import logging +from typing import Any + +from ttn_client import TTNAuthError, TTNClient +import voluptuous as vol + +from homeassistant.config_entries import ConfigEntry, ConfigFlow, ConfigFlowResult +from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.helpers.selector import ( + TextSelector, + TextSelectorConfig, + TextSelectorType, +) + +from .const import CONF_APP_ID, DOMAIN, TTN_API_HOST + +_LOGGER = logging.getLogger(__name__) + + +class TTNFlowHandler(ConfigFlow, domain=DOMAIN): + """Handle a config flow.""" + + VERSION = 1 + + _reauth_entry: ConfigEntry | None = None + + async def async_step_user( + self, user_input: Mapping[str, Any] | None = None + ) -> ConfigFlowResult: + """User initiated config flow.""" + + errors = {} + if user_input is not None: + client = TTNClient( + user_input[CONF_HOST], + user_input[CONF_APP_ID], + user_input[CONF_API_KEY], + 0, + ) + try: + await client.fetch_data() + except TTNAuthError: + _LOGGER.exception("Error authenticating with The Things Network") + errors["base"] = "invalid_auth" + except Exception: + _LOGGER.exception("Unknown error occurred") + errors["base"] = "unknown" + + if not errors: + # Create entry + if self._reauth_entry: + return self.async_update_reload_and_abort( + self._reauth_entry, + data=user_input, + reason="reauth_successful", + ) + await self.async_set_unique_id(user_input[CONF_APP_ID]) + self._abort_if_unique_id_configured() + + return self.async_create_entry( + title=str(user_input[CONF_APP_ID]), + data=user_input, + ) + + # Show form for user to provide settings + if not user_input: + if self._reauth_entry: + user_input = self._reauth_entry.data + else: + user_input = {CONF_HOST: TTN_API_HOST} + + schema = self.add_suggested_values_to_schema( + vol.Schema( + { + vol.Required(CONF_HOST): str, + vol.Required(CONF_APP_ID): str, + vol.Required(CONF_API_KEY): TextSelector( + TextSelectorConfig( + type=TextSelectorType.PASSWORD, autocomplete="api_key" + ) + ), + } + ), + user_input, + ) + return self.async_show_form(step_id="user", data_schema=schema, errors=errors) + + async def async_step_reauth( + self, user_input: Mapping[str, Any] + ) -> ConfigFlowResult: + """Handle a flow initialized by a reauth event.""" + + self._reauth_entry = self.hass.config_entries.async_get_entry( + self.context["entry_id"] + ) + + return await self.async_step_reauth_confirm() + + async def async_step_reauth_confirm( + self, user_input: dict[str, Any] | None = None + ) -> ConfigFlowResult: + """Dialog that informs the user that reauth is required.""" + if user_input is None: + return self.async_show_form(step_id="reauth_confirm") + return await self.async_step_user() diff --git a/homeassistant/components/thethingsnetwork/const.py b/homeassistant/components/thethingsnetwork/const.py new file mode 100644 index 00000000000..1a0b5da7184 --- /dev/null +++ b/homeassistant/components/thethingsnetwork/const.py @@ -0,0 +1,12 @@ +"""The Things Network's integration constants.""" + +from homeassistant.const import Platform + +DOMAIN = "thethingsnetwork" +TTN_API_HOST = "eu1.cloud.thethings.network" + +PLATFORMS = [Platform.SENSOR] + +CONF_APP_ID = "app_id" + +POLLING_PERIOD_S = 60 diff --git a/homeassistant/components/thethingsnetwork/coordinator.py b/homeassistant/components/thethingsnetwork/coordinator.py new file mode 100644 index 00000000000..64608c2f064 --- /dev/null +++ b/homeassistant/components/thethingsnetwork/coordinator.py @@ -0,0 +1,66 @@ +"""The Things Network's integration DataUpdateCoordinator.""" + +from datetime import timedelta +import logging + +from ttn_client import TTNAuthError, TTNClient + +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.exceptions import ConfigEntryAuthFailed +from homeassistant.helpers.update_coordinator import DataUpdateCoordinator + +from .const import CONF_APP_ID, POLLING_PERIOD_S + +_LOGGER = logging.getLogger(__name__) + + +class TTNCoordinator(DataUpdateCoordinator[TTNClient.DATA_TYPE]): + """TTN coordinator.""" + + def __init__(self, hass: HomeAssistant, entry: ConfigEntry) -> None: + """Initialize my coordinator.""" + super().__init__( + hass, + _LOGGER, + # Name of the data. For logging purposes. + name=f"TheThingsNetwork_{entry.data[CONF_APP_ID]}", + # Polling interval. Will only be polled if there are subscribers. + update_interval=timedelta( + seconds=POLLING_PERIOD_S, + ), + ) + + self._client = TTNClient( + entry.data[CONF_HOST], + entry.data[CONF_APP_ID], + entry.data[CONF_API_KEY], + push_callback=self._push_callback, + ) + + async def _async_update_data(self) -> TTNClient.DATA_TYPE: + """Fetch data from API endpoint. + + This is the place to pre-process the data to lookup tables + so entities can quickly look up their data. + """ + try: + # Note: asyncio.TimeoutError and aiohttp.ClientError are already + # handled by the data update coordinator. + measurements = await self._client.fetch_data() + except TTNAuthError as err: + # Raising ConfigEntryAuthFailed will cancel future updates + # and start a config flow with SOURCE_REAUTH (async_step_reauth) + _LOGGER.error("TTNAuthError") + raise ConfigEntryAuthFailed from err + else: + # Return measurements + _LOGGER.debug("fetched data: %s", measurements) + return measurements + + async def _push_callback(self, data: TTNClient.DATA_TYPE) -> None: + _LOGGER.debug("pushed data: %s", data) + + # Push data to entities + self.async_set_updated_data(data) diff --git a/homeassistant/components/thethingsnetwork/entity.py b/homeassistant/components/thethingsnetwork/entity.py new file mode 100644 index 00000000000..0a86f153cc9 --- /dev/null +++ b/homeassistant/components/thethingsnetwork/entity.py @@ -0,0 +1,71 @@ +"""Support for The Things Network entities.""" + +import logging + +from ttn_client import TTNBaseValue + +from homeassistant.core import callback +from homeassistant.helpers.device_registry import DeviceInfo +from homeassistant.helpers.update_coordinator import CoordinatorEntity + +from .const import DOMAIN +from .coordinator import TTNCoordinator + +_LOGGER = logging.getLogger(__name__) + + +class TTNEntity(CoordinatorEntity[TTNCoordinator]): + """Representation of a The Things Network Data Storage sensor.""" + + _attr_has_entity_name = True + _ttn_value: TTNBaseValue + + def __init__( + self, + coordinator: TTNCoordinator, + app_id: str, + ttn_value: TTNBaseValue, + ) -> None: + """Initialize a The Things Network Data Storage sensor.""" + + # Pass coordinator to CoordinatorEntity + super().__init__(coordinator) + + self._ttn_value = ttn_value + + self._attr_unique_id = f"{self.device_id}_{self.field_id}" + self._attr_name = self.field_id + + self._attr_device_info = DeviceInfo( + identifiers={(DOMAIN, f"{app_id}_{self.device_id}")}, + name=self.device_id, + ) + + @callback + def _handle_coordinator_update(self) -> None: + """Handle updated data from the coordinator.""" + + my_entity_update = self.coordinator.data.get(self.device_id, {}).get( + self.field_id + ) + if ( + my_entity_update + and my_entity_update.received_at > self._ttn_value.received_at + ): + _LOGGER.debug( + "Received update for %s: %s", self.unique_id, my_entity_update + ) + # Check that the type of an entity has not changed since the creation + assert isinstance(my_entity_update, type(self._ttn_value)) + self._ttn_value = my_entity_update + self.async_write_ha_state() + + @property + def device_id(self) -> str: + """Return device_id.""" + return str(self._ttn_value.device_id) + + @property + def field_id(self) -> str: + """Return field_id.""" + return str(self._ttn_value.field_id) diff --git a/homeassistant/components/thethingsnetwork/manifest.json b/homeassistant/components/thethingsnetwork/manifest.json index 4b298a33198..b8b1dbd7e1d 100644 --- a/homeassistant/components/thethingsnetwork/manifest.json +++ b/homeassistant/components/thethingsnetwork/manifest.json @@ -1,7 +1,10 @@ { "domain": "thethingsnetwork", "name": "The Things Network", - "codeowners": ["@fabaff"], + "codeowners": ["@angelnu"], + "config_flow": true, "documentation": "https://www.home-assistant.io/integrations/thethingsnetwork", - "iot_class": "local_push" + "integration_type": "hub", + "iot_class": "cloud_polling", + "requirements": ["ttn_client==0.0.4"] } diff --git a/homeassistant/components/thethingsnetwork/sensor.py b/homeassistant/components/thethingsnetwork/sensor.py index ae4fed8600e..82dd169a52d 100644 --- a/homeassistant/components/thethingsnetwork/sensor.py +++ b/homeassistant/components/thethingsnetwork/sensor.py @@ -1,165 +1,56 @@ -"""Support for The Things Network's Data storage integration.""" +"""The Things Network's integration sensors.""" -from __future__ import annotations - -import asyncio -from http import HTTPStatus import logging -import aiohttp -from aiohttp.hdrs import ACCEPT, AUTHORIZATION -import voluptuous as vol +from ttn_client import TTNSensorValue -from homeassistant.components.sensor import PLATFORM_SCHEMA, SensorEntity -from homeassistant.const import ( - ATTR_DEVICE_ID, - ATTR_TIME, - CONF_DEVICE_ID, - CONTENT_TYPE_JSON, -) +from homeassistant.components.sensor import SensorEntity, StateType +from homeassistant.config_entries import ConfigEntry from homeassistant.core import HomeAssistant -from homeassistant.helpers.aiohttp_client import async_get_clientsession -import homeassistant.helpers.config_validation as cv from homeassistant.helpers.entity_platform import AddEntitiesCallback -from homeassistant.helpers.typing import ConfigType, DiscoveryInfoType -from . import DATA_TTN, TTN_ACCESS_KEY, TTN_APP_ID, TTN_DATA_STORAGE_URL +from .const import CONF_APP_ID, DOMAIN +from .entity import TTNEntity _LOGGER = logging.getLogger(__name__) -ATTR_RAW = "raw" -DEFAULT_TIMEOUT = 10 -CONF_VALUES = "values" - -PLATFORM_SCHEMA = PLATFORM_SCHEMA.extend( - { - vol.Required(CONF_DEVICE_ID): cv.string, - vol.Required(CONF_VALUES): {cv.string: cv.string}, - } -) - - -async def async_setup_platform( - hass: HomeAssistant, - config: ConfigType, - async_add_entities: AddEntitiesCallback, - discovery_info: DiscoveryInfoType | None = None, +async def async_setup_entry( + hass: HomeAssistant, entry: ConfigEntry, async_add_entities: AddEntitiesCallback ) -> None: - """Set up The Things Network Data storage sensors.""" - ttn = hass.data[DATA_TTN] - device_id = config[CONF_DEVICE_ID] - values = config[CONF_VALUES] - app_id = ttn.get(TTN_APP_ID) - access_key = ttn.get(TTN_ACCESS_KEY) + """Add entities for TTN.""" - ttn_data_storage = TtnDataStorage(hass, app_id, device_id, access_key, values) - success = await ttn_data_storage.async_update() + coordinator = hass.data[DOMAIN][entry.entry_id] - if not success: - return + sensors: set[tuple[str, str]] = set() - devices = [] - for value, unit_of_measurement in values.items(): - devices.append( - TtnDataSensor(ttn_data_storage, device_id, value, unit_of_measurement) - ) - async_add_entities(devices, True) + def _async_measurement_listener() -> None: + data = coordinator.data + new_sensors = { + (device_id, field_id): TtnDataSensor( + coordinator, + entry.data[CONF_APP_ID], + ttn_value, + ) + for device_id, device_uplinks in data.items() + for field_id, ttn_value in device_uplinks.items() + if (device_id, field_id) not in sensors + and isinstance(ttn_value, TTNSensorValue) + } + if len(new_sensors): + async_add_entities(new_sensors.values()) + sensors.update(new_sensors.keys()) + + entry.async_on_unload(coordinator.async_add_listener(_async_measurement_listener)) + _async_measurement_listener() -class TtnDataSensor(SensorEntity): - """Representation of a The Things Network Data Storage sensor.""" +class TtnDataSensor(TTNEntity, SensorEntity): + """Represents a TTN Home Assistant Sensor.""" - def __init__(self, ttn_data_storage, device_id, value, unit_of_measurement): - """Initialize a The Things Network Data Storage sensor.""" - self._ttn_data_storage = ttn_data_storage - self._state = None - self._device_id = device_id - self._unit_of_measurement = unit_of_measurement - self._value = value - self._name = f"{self._device_id} {self._value}" + _ttn_value: TTNSensorValue @property - def name(self): - """Return the name of the sensor.""" - return self._name - - @property - def native_value(self): + def native_value(self) -> StateType: """Return the state of the entity.""" - if self._ttn_data_storage.data is not None: - try: - return self._state[self._value] - except KeyError: - return None - return None - - @property - def native_unit_of_measurement(self): - """Return the unit this state is expressed in.""" - return self._unit_of_measurement - - @property - def extra_state_attributes(self): - """Return the state attributes of the sensor.""" - if self._ttn_data_storage.data is not None: - return { - ATTR_DEVICE_ID: self._device_id, - ATTR_RAW: self._state["raw"], - ATTR_TIME: self._state["time"], - } - - async def async_update(self) -> None: - """Get the current state.""" - await self._ttn_data_storage.async_update() - self._state = self._ttn_data_storage.data - - -class TtnDataStorage: - """Get the latest data from The Things Network Data Storage.""" - - def __init__(self, hass, app_id, device_id, access_key, values): - """Initialize the data object.""" - self.data = None - self._hass = hass - self._app_id = app_id - self._device_id = device_id - self._values = values - self._url = TTN_DATA_STORAGE_URL.format( - app_id=app_id, endpoint="api/v2/query", device_id=device_id - ) - self._headers = {ACCEPT: CONTENT_TYPE_JSON, AUTHORIZATION: f"key {access_key}"} - - async def async_update(self): - """Get the current state from The Things Network Data Storage.""" - try: - session = async_get_clientsession(self._hass) - async with asyncio.timeout(DEFAULT_TIMEOUT): - response = await session.get(self._url, headers=self._headers) - - except (TimeoutError, aiohttp.ClientError): - _LOGGER.error("Error while accessing: %s", self._url) - return None - - status = response.status - - if status == HTTPStatus.NO_CONTENT: - _LOGGER.error("The device is not available: %s", self._device_id) - return None - - if status == HTTPStatus.UNAUTHORIZED: - _LOGGER.error("Not authorized for Application ID: %s", self._app_id) - return None - - if status == HTTPStatus.NOT_FOUND: - _LOGGER.error("Application ID is not available: %s", self._app_id) - return None - - data = await response.json() - self.data = data[-1] - - for value in self._values.items(): - if value[0] not in self.data: - _LOGGER.warning("Value not available: %s", value[0]) - - return response + return self._ttn_value.value diff --git a/homeassistant/components/thethingsnetwork/strings.json b/homeassistant/components/thethingsnetwork/strings.json new file mode 100644 index 00000000000..98572cb318c --- /dev/null +++ b/homeassistant/components/thethingsnetwork/strings.json @@ -0,0 +1,32 @@ +{ + "config": { + "step": { + "user": { + "title": "Connect to The Things Network v3 App", + "description": "Enter the API hostname, app id and API key for your TTN application.\n\nYou can find your API key in the [The Things Network console](https://console.thethingsnetwork.org) -> Applications -> application_id -> API keys.", + "data": { + "hostname": "[%key:common::config_flow::data::host%]", + "app_id": "Application ID", + "access_key": "[%key:common::config_flow::data::api_key%]" + } + }, + "reauth_confirm": { + "description": "The Things Network application could not be connected.\n\nPlease check your credentials." + } + }, + "abort": { + "already_configured": "Application ID is already configured", + "reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]" + }, + "error": { + "invalid_auth": "[%key:common::config_flow::error::invalid_auth%]", + "unknown": "[%key:common::config_flow::error::unknown%]" + } + }, + "issues": { + "manual_migration": { + "description": "Configuring {domain} using YAML was removed as part of migrating to [The Things Network v3]({v2_v3_migration_url}). [The Things Network v2 has shutted down]({v2_deprecation_url}).\n\nPlease remove the {domain} entry from the configuration.yaml and add re-add the integration using the config_flow", + "title": "The {domain} YAML configuration is not supported" + } + } +} diff --git a/homeassistant/components/unifi/__init__.py b/homeassistant/components/unifi/__init__.py index 1c2ee5ee4ae..b893b612f2a 100644 --- a/homeassistant/components/unifi/__init__.py +++ b/homeassistant/components/unifi/__init__.py @@ -39,8 +39,6 @@ async def async_setup_entry( hass: HomeAssistant, config_entry: UnifiConfigEntry ) -> bool: """Set up the UniFi Network integration.""" - hass.data.setdefault(UNIFI_DOMAIN, {}) - try: api = await get_unifi_api(hass, config_entry.data) @@ -62,7 +60,6 @@ async def async_setup_entry( config_entry.async_on_unload( hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, hub.shutdown) ) - return True diff --git a/homeassistant/components/websocket_api/messages.py b/homeassistant/components/websocket_api/messages.py index 98db92dfef7..238f8be0c3b 100644 --- a/homeassistant/components/websocket_api/messages.py +++ b/homeassistant/components/websocket_api/messages.py @@ -15,7 +15,7 @@ from homeassistant.const import ( COMPRESSED_STATE_LAST_UPDATED, COMPRESSED_STATE_STATE, ) -from homeassistant.core import Event, EventStateChangedData, State +from homeassistant.core import CompressedState, Event, EventStateChangedData from homeassistant.helpers import config_validation as cv from homeassistant.helpers.json import ( JSON_DUMP, @@ -177,7 +177,14 @@ def _partial_cached_state_diff_message(event: Event[EventStateChangedData]) -> b ) -def _state_diff_event(event: Event[EventStateChangedData]) -> dict: +def _state_diff_event( + event: Event[EventStateChangedData], +) -> dict[ + str, + list[str] + | dict[str, CompressedState] + | dict[str, dict[str, dict[str, str | list[str]]]], +]: """Convert a state_changed event to the minimal version. State update example @@ -188,21 +195,10 @@ def _state_diff_event(event: Event[EventStateChangedData]) -> dict: "r": [entity_id,…] } """ - if (event_new_state := event.data["new_state"]) is None: + if (new_state := event.data["new_state"]) is None: return {ENTITY_EVENT_REMOVE: [event.data["entity_id"]]} - if (event_old_state := event.data["old_state"]) is None: - return { - ENTITY_EVENT_ADD: { - event_new_state.entity_id: event_new_state.as_compressed_state - } - } - return _state_diff(event_old_state, event_new_state) - - -def _state_diff( - old_state: State, new_state: State -) -> dict[str, dict[str, dict[str, dict[str, str | list[str]]]]]: - """Create a diff dict that can be used to overlay changes.""" + if (old_state := event.data["old_state"]) is None: + return {ENTITY_EVENT_ADD: {new_state.entity_id: new_state.as_compressed_state}} additions: dict[str, Any] = {} diff: dict[str, dict[str, Any]] = {STATE_DIFF_ADDITIONS: additions} new_state_context = new_state.context diff --git a/homeassistant/components/workday/manifest.json b/homeassistant/components/workday/manifest.json index 4f1815cd239..7faf82ad71a 100644 --- a/homeassistant/components/workday/manifest.json +++ b/homeassistant/components/workday/manifest.json @@ -7,5 +7,5 @@ "iot_class": "local_polling", "loggers": ["holidays"], "quality_scale": "internal", - "requirements": ["holidays==0.48"] + "requirements": ["holidays==0.49"] } diff --git a/homeassistant/core.py b/homeassistant/core.py index 48a600ae1c9..9c5d8612b27 100644 --- a/homeassistant/core.py +++ b/homeassistant/core.py @@ -268,8 +268,16 @@ def async_get_hass() -> HomeAssistant: This should be used where it's very cumbersome or downright impossible to pass hass to the code which needs it. """ - if not _hass.hass: + if not (hass := async_get_hass_or_none()): raise HomeAssistantError("async_get_hass called from the wrong thread") + return hass + + +def async_get_hass_or_none() -> HomeAssistant | None: + """Return the HomeAssistant instance or None. + + Returns None when called from the wrong thread. + """ return _hass.hass diff --git a/homeassistant/generated/config_flows.py b/homeassistant/generated/config_flows.py index e4ab6db9f48..b421fbd13ad 100644 --- a/homeassistant/generated/config_flows.py +++ b/homeassistant/generated/config_flows.py @@ -551,6 +551,7 @@ FLOWS = { "tessie", "thermobeacon", "thermopro", + "thethingsnetwork", "thread", "tibber", "tile", diff --git a/homeassistant/generated/integrations.json b/homeassistant/generated/integrations.json index 936e2d586fb..42088eaea8d 100644 --- a/homeassistant/generated/integrations.json +++ b/homeassistant/generated/integrations.json @@ -6146,8 +6146,8 @@ "thethingsnetwork": { "name": "The Things Network", "integration_type": "hub", - "config_flow": false, - "iot_class": "local_push" + "config_flow": true, + "iot_class": "cloud_polling" }, "thingspeak": { "name": "ThingSpeak", diff --git a/homeassistant/helpers/config_validation.py b/homeassistant/helpers/config_validation.py index a7754f9aaa8..295cd13fed4 100644 --- a/homeassistant/helpers/config_validation.py +++ b/homeassistant/helpers/config_validation.py @@ -93,8 +93,8 @@ from homeassistant.const import ( ) from homeassistant.core import ( DOMAIN as HOMEASSISTANT_DOMAIN, - HomeAssistant, async_get_hass, + async_get_hass_or_none, split_entity_id, valid_entity_id, ) @@ -662,11 +662,7 @@ def template(value: Any | None) -> template_helper.Template: if isinstance(value, (list, dict, template_helper.Template)): raise vol.Invalid("template value should be a string") - hass: HomeAssistant | None = None - with contextlib.suppress(HomeAssistantError): - hass = async_get_hass() - - template_value = template_helper.Template(str(value), hass) + template_value = template_helper.Template(str(value), async_get_hass_or_none()) try: template_value.ensure_valid() @@ -684,11 +680,7 @@ def dynamic_template(value: Any | None) -> template_helper.Template: if not template_helper.is_template_string(str(value)): raise vol.Invalid("template value does not contain a dynamic template") - hass: HomeAssistant | None = None - with contextlib.suppress(HomeAssistantError): - hass = async_get_hass() - - template_value = template_helper.Template(str(value), hass) + template_value = template_helper.Template(str(value), async_get_hass_or_none()) try: template_value.ensure_valid() diff --git a/homeassistant/helpers/deprecation.py b/homeassistant/helpers/deprecation.py index 79dd436db95..82ff136332b 100644 --- a/homeassistant/helpers/deprecation.py +++ b/homeassistant/helpers/deprecation.py @@ -3,7 +3,6 @@ from __future__ import annotations from collections.abc import Callable -from contextlib import suppress from enum import Enum import functools import inspect @@ -167,8 +166,7 @@ def _print_deprecation_warning_internal( log_when_no_integration_is_found: bool, ) -> None: # pylint: disable=import-outside-toplevel - from homeassistant.core import HomeAssistant, async_get_hass - from homeassistant.exceptions import HomeAssistantError + from homeassistant.core import async_get_hass_or_none from homeassistant.loader import async_suggest_report_issue from .frame import MissingIntegrationFrame, get_integration_frame @@ -191,11 +189,8 @@ def _print_deprecation_warning_internal( ) else: if integration_frame.custom_integration: - hass: HomeAssistant | None = None - with suppress(HomeAssistantError): - hass = async_get_hass() report_issue = async_suggest_report_issue( - hass, + async_get_hass_or_none(), integration_domain=integration_frame.integration, module=integration_frame.module, ) diff --git a/homeassistant/helpers/frame.py b/homeassistant/helpers/frame.py index 321094ba8d9..3046b718489 100644 --- a/homeassistant/helpers/frame.py +++ b/homeassistant/helpers/frame.py @@ -4,7 +4,6 @@ from __future__ import annotations import asyncio from collections.abc import Callable -from contextlib import suppress from dataclasses import dataclass import functools from functools import cached_property @@ -14,7 +13,7 @@ import sys from types import FrameType from typing import Any, cast -from homeassistant.core import HomeAssistant, async_get_hass +from homeassistant.core import async_get_hass_or_none from homeassistant.exceptions import HomeAssistantError from homeassistant.loader import async_suggest_report_issue @@ -176,11 +175,8 @@ def _report_integration( return _REPORTED_INTEGRATIONS.add(key) - hass: HomeAssistant | None = None - with suppress(HomeAssistantError): - hass = async_get_hass() report_issue = async_suggest_report_issue( - hass, + async_get_hass_or_none(), integration_domain=integration_frame.integration, module=integration_frame.module, ) diff --git a/homeassistant/helpers/llm.py b/homeassistant/helpers/llm.py index 08125acc0da..e09af97620c 100644 --- a/homeassistant/helpers/llm.py +++ b/homeassistant/helpers/llm.py @@ -23,10 +23,14 @@ from .singleton import singleton LLM_API_ASSIST = "assist" -PROMPT_NO_API_CONFIGURED = ( - "Only if the user wants to control a device, tell them to edit the AI configuration " - "and allow access to Home Assistant." -) + +@callback +def async_render_no_api_prompt(hass: HomeAssistant) -> str: + """Return the prompt to be used when no API is configured.""" + return ( + "Only if the user wants to control a device, tell them to edit the AI configuration " + "and allow access to Home Assistant." + ) @singleton("llm") diff --git a/homeassistant/util/loop.py b/homeassistant/util/loop.py index accb63198ba..cba9f7c3900 100644 --- a/homeassistant/util/loop.py +++ b/homeassistant/util/loop.py @@ -3,15 +3,13 @@ from __future__ import annotations from collections.abc import Callable -from contextlib import suppress import functools import linecache import logging import threading from typing import Any -from homeassistant.core import HomeAssistant, async_get_hass -from homeassistant.exceptions import HomeAssistantError +from homeassistant.core import async_get_hass_or_none from homeassistant.helpers.frame import ( MissingIntegrationFrame, get_current_frame, @@ -74,11 +72,8 @@ def raise_for_blocking_call( f"https://github.com/home-assistant/core/issues?q=is%3Aopen+is%3Aissue" ) - hass: HomeAssistant | None = None - with suppress(HomeAssistantError): - hass = async_get_hass() report_issue = async_suggest_report_issue( - hass, + async_get_hass_or_none(), integration_domain=integration_frame.integration, module=integration_frame.module, ) diff --git a/mypy.ini b/mypy.ini index ffd3db822dd..4e4d9cc624b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -4044,6 +4044,16 @@ disallow_untyped_defs = true warn_return_any = true warn_unreachable = true +[mypy-homeassistant.components.thethingsnetwork.*] +check_untyped_defs = true +disallow_incomplete_defs = true +disallow_subclassing_any = true +disallow_untyped_calls = true +disallow_untyped_decorators = true +disallow_untyped_defs = true +warn_return_any = true +warn_unreachable = true + [mypy-homeassistant.components.threshold.*] check_untyped_defs = true disallow_incomplete_defs = true diff --git a/requirements_all.txt b/requirements_all.txt index c9f6eade715..78688d663e2 100644 --- a/requirements_all.txt +++ b/requirements_all.txt @@ -1084,7 +1084,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.48 +holidays==0.49 # homeassistant.components.frontend home-assistant-frontend==20240501.1 @@ -2763,6 +2763,9 @@ transmission-rpc==7.0.3 # homeassistant.components.twinkly ttls==1.5.1 +# homeassistant.components.thethingsnetwork +ttn_client==0.0.4 + # homeassistant.components.tuya tuya-device-sharing-sdk==0.1.9 diff --git a/requirements_test_all.txt b/requirements_test_all.txt index b2915017c01..ef036f6e4a4 100644 --- a/requirements_test_all.txt +++ b/requirements_test_all.txt @@ -886,7 +886,7 @@ hole==0.8.0 # homeassistant.components.holiday # homeassistant.components.workday -holidays==0.48 +holidays==0.49 # homeassistant.components.frontend home-assistant-frontend==20240501.1 @@ -2137,6 +2137,9 @@ transmission-rpc==7.0.3 # homeassistant.components.twinkly ttls==1.5.1 +# homeassistant.components.thethingsnetwork +ttn_client==0.0.4 + # homeassistant.components.tuya tuya-device-sharing-sdk==0.1.9 diff --git a/tests/components/google_generative_ai_conversation/conftest.py b/tests/components/google_generative_ai_conversation/conftest.py index 8ab8020428e..7c4aef75776 100644 --- a/tests/components/google_generative_ai_conversation/conftest.py +++ b/tests/components/google_generative_ai_conversation/conftest.py @@ -17,8 +17,7 @@ from tests.common import MockConfigEntry def mock_genai(): """Mock the genai call in async_setup_entry.""" with patch( - "homeassistant.components.google_generative_ai_conversation.genai.list_models", - return_value=iter([]), + "homeassistant.components.google_generative_ai_conversation.genai.get_model" ): yield diff --git a/tests/components/google_generative_ai_conversation/test_config_flow.py b/tests/components/google_generative_ai_conversation/test_config_flow.py index 55350325eee..805fb9c3c74 100644 --- a/tests/components/google_generative_ai_conversation/test_config_flow.py +++ b/tests/components/google_generative_ai_conversation/test_config_flow.py @@ -2,7 +2,7 @@ from unittest.mock import Mock, patch -from google.api_core.exceptions import ClientError +from google.api_core.exceptions import ClientError, DeadlineExceeded from google.rpc.error_details_pb2 import ErrorInfo import pytest @@ -69,7 +69,7 @@ async def test_form(hass: HomeAssistant) -> None: DOMAIN, context={"source": config_entries.SOURCE_USER} ) assert result["type"] is FlowResultType.FORM - assert result["errors"] is None + assert not result["errors"] with ( patch( @@ -186,13 +186,16 @@ async def test_options_switching( ("side_effect", "error"), [ ( - ClientError(message="some error"), + ClientError("some error"), + "cannot_connect", + ), + ( + DeadlineExceeded("deadline exceeded"), "cannot_connect", ), ( ClientError( - message="invalid api key", - error_info=ErrorInfo(reason="API_KEY_INVALID"), + "invalid api key", error_info=ErrorInfo(reason="API_KEY_INVALID") ), "invalid_auth", ), @@ -218,3 +221,51 @@ async def test_form_errors(hass: HomeAssistant, side_effect, error) -> None: assert result2["type"] is FlowResultType.FORM assert result2["errors"] == {"base": error} + + +async def test_reauth_flow(hass: HomeAssistant) -> None: + """Test the reauth flow.""" + hass.config.components.add("google_generative_ai_conversation") + mock_config_entry = MockConfigEntry( + domain=DOMAIN, state=config_entries.ConfigEntryState.LOADED, title="Gemini" + ) + mock_config_entry.add_to_hass(hass) + mock_config_entry.async_start_reauth(hass) + await hass.async_block_till_done() + + flows = hass.config_entries.flow.async_progress() + assert len(flows) == 1 + result = flows[0] + assert result["step_id"] == "reauth_confirm" + assert result["context"]["source"] == "reauth" + assert result["context"]["title_placeholders"] == {"name": "Gemini"} + + result = await hass.config_entries.flow.async_configure(result["flow_id"], {}) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "api" + assert "api_key" in result["data_schema"].schema + assert not result["errors"] + + with ( + patch( + "homeassistant.components.google_generative_ai_conversation.config_flow.genai.list_models", + ), + patch( + "homeassistant.components.google_generative_ai_conversation.async_setup_entry", + return_value=True, + ) as mock_setup_entry, + patch( + "homeassistant.components.google_generative_ai_conversation.async_unload_entry", + return_value=True, + ) as mock_unload_entry, + ): + result = await hass.config_entries.flow.async_configure( + result["flow_id"], {"api_key": "1234"} + ) + await hass.async_block_till_done() + + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + assert hass.config_entries.async_entries(DOMAIN)[0].data == {"api_key": "1234"} + assert len(mock_unload_entry.mock_calls) == 1 + assert len(mock_setup_entry.mock_calls) == 1 diff --git a/tests/components/google_generative_ai_conversation/test_init.py b/tests/components/google_generative_ai_conversation/test_init.py index a6a5fdf0b0e..44096e98469 100644 --- a/tests/components/google_generative_ai_conversation/test_init.py +++ b/tests/components/google_generative_ai_conversation/test_init.py @@ -2,7 +2,8 @@ from unittest.mock import AsyncMock, MagicMock, patch -from google.api_core.exceptions import ClientError +from google.api_core.exceptions import ClientError, DeadlineExceeded +from google.rpc.error_details_pb2 import ErrorInfo import pytest from syrupy.assertion import SnapshotAssertion @@ -220,29 +221,39 @@ async def test_generate_content_service_with_non_image( ) -async def test_config_entry_not_ready( - hass: HomeAssistant, mock_config_entry: MockConfigEntry +@pytest.mark.parametrize( + ("side_effect", "state", "reauth"), + [ + ( + ClientError("some error"), + ConfigEntryState.SETUP_ERROR, + False, + ), + ( + DeadlineExceeded("deadline exceeded"), + ConfigEntryState.SETUP_RETRY, + False, + ), + ( + ClientError( + "invalid api key", error_info=ErrorInfo(reason="API_KEY_INVALID") + ), + ConfigEntryState.SETUP_ERROR, + True, + ), + ], +) +async def test_config_entry_error( + hass: HomeAssistant, mock_config_entry: MockConfigEntry, side_effect, state, reauth ) -> None: - """Test configuration entry not ready.""" + """Test different configuration entry errors.""" with patch( - "homeassistant.components.google_generative_ai_conversation.genai.list_models", - side_effect=ClientError("error"), + "homeassistant.components.google_generative_ai_conversation.genai.get_model", + side_effect=side_effect, ): mock_config_entry.add_to_hass(hass) await hass.config_entries.async_setup(mock_config_entry.entry_id) await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.SETUP_RETRY - - -async def test_config_entry_setup_error( - hass: HomeAssistant, mock_config_entry: MockConfigEntry -) -> None: - """Test configuration entry setup error.""" - with patch( - "homeassistant.components.google_generative_ai_conversation.genai.list_models", - side_effect=ClientError("error", error_info="API_KEY_INVALID"), - ): - mock_config_entry.add_to_hass(hass) - await hass.config_entries.async_setup(mock_config_entry.entry_id) - await hass.async_block_till_done() - assert mock_config_entry.state is ConfigEntryState.SETUP_ERROR + assert mock_config_entry.state is state + mock_config_entry.async_get_active_flows(hass, {"reauth"}) + assert any(mock_config_entry.async_get_active_flows(hass, {"reauth"})) is reauth diff --git a/tests/components/hassio/test_issues.py b/tests/components/hassio/test_issues.py index 2da9d30549d..c6db7d56261 100644 --- a/tests/components/hassio/test_issues.py +++ b/tests/components/hassio/test_issues.py @@ -27,11 +27,6 @@ async def setup_repairs(hass): assert await async_setup_component(hass, REPAIRS_DOMAIN, {REPAIRS_DOMAIN: {}}) -@pytest.fixture(autouse=True) -async def mock_all(all_setup_requests): - """Mock all setup requests.""" - - @pytest.fixture(autouse=True) async def fixture_supervisor_environ(): """Mock os environ for supervisor.""" @@ -110,9 +105,13 @@ def assert_issue_repair_in_list( context: str, type_: str, fixable: bool, - reference: str | None, + *, + reference: str | None = None, + placeholders: dict[str, str] | None = None, ): """Assert repair for unhealthy/unsupported in list.""" + if reference: + placeholders = (placeholders or {}) | {"reference": reference} assert { "breaks_in_ha_version": None, "created": ANY, @@ -125,7 +124,7 @@ def assert_issue_repair_in_list( "learn_more_url": None, "severity": "warning", "translation_key": f"issue_{context}_{type_}", - "translation_placeholders": {"reference": reference} if reference else None, + "translation_placeholders": placeholders, } in issues @@ -133,6 +132,7 @@ async def test_unhealthy_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test issues added for unhealthy systems.""" mock_resolution_info(aioclient_mock, unhealthy=["docker", "setup"]) @@ -154,6 +154,7 @@ async def test_unsupported_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test issues added for unsupported systems.""" mock_resolution_info(aioclient_mock, unsupported=["content_trust", "os"]) @@ -177,6 +178,7 @@ async def test_unhealthy_issues_add_remove( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test unhealthy issues added and removed from dispatches.""" mock_resolution_info(aioclient_mock) @@ -233,6 +235,7 @@ async def test_unsupported_issues_add_remove( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test unsupported issues added and removed from dispatches.""" mock_resolution_info(aioclient_mock) @@ -289,6 +292,7 @@ async def test_reset_issues_supervisor_restart( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """All issues reset on supervisor restart.""" mock_resolution_info( @@ -352,6 +356,7 @@ async def test_reasons_added_and_removed( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test an unsupported/unhealthy reasons being added and removed at same time.""" mock_resolution_info(aioclient_mock, unsupported=["os"], unhealthy=["docker"]) @@ -401,6 +406,7 @@ async def test_ignored_unsupported_skipped( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Unsupported reasons which have an identical unhealthy reason are ignored.""" mock_resolution_info( @@ -423,6 +429,7 @@ async def test_new_unsupported_unhealthy_reason( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """New unsupported/unhealthy reasons result in a generic repair until next core update.""" mock_resolution_info( @@ -472,6 +479,7 @@ async def test_supervisor_issues( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test repairs added for supervisor issue.""" mock_resolution_info( @@ -538,6 +546,7 @@ async def test_supervisor_issues_initial_failure( aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, freezer: FrozenDateTimeFactory, + all_setup_requests, ) -> None: """Test issues manager retries after initial update failure.""" responses = [ @@ -614,6 +623,7 @@ async def test_supervisor_issues_add_remove( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test supervisor issues added and removed from dispatches.""" mock_resolution_info(aioclient_mock) @@ -724,6 +734,7 @@ async def test_supervisor_issues_suggestions_fail( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test failing to get suggestions for issue skips it.""" aioclient_mock.get( @@ -769,6 +780,7 @@ async def test_supervisor_remove_missing_issue_without_error( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, hass_ws_client: WebSocketGenerator, + all_setup_requests, ) -> None: """Test HA skips message to remove issue that it didn't know about (sync issue).""" mock_resolution_info(aioclient_mock) @@ -802,6 +814,7 @@ async def test_system_is_not_ready( hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, caplog: pytest.LogCaptureFixture, + all_setup_requests, ) -> None: """Ensure hassio starts despite error.""" aioclient_mock.get( @@ -814,3 +827,57 @@ async def test_system_is_not_ready( assert await async_setup_component(hass, "hassio", {}) assert "Failed to update supervisor issues" in caplog.text + + +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +async def test_supervisor_issues_detached_addon_missing( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_ws_client: WebSocketGenerator, + all_setup_requests, +) -> None: + """Test supervisor issue for detached addon due to missing repository.""" + mock_resolution_info(aioclient_mock) + + result = await async_setup_component(hass, "hassio", {}) + assert result + + client = await hass_ws_client(hass) + + await client.send_json( + { + "id": 1, + "type": "supervisor/event", + "data": { + "event": "issue_changed", + "data": { + "uuid": "1234", + "type": "detached_addon_missing", + "context": "addon", + "reference": "test", + }, + }, + } + ) + msg = await client.receive_json() + assert msg["success"] + await hass.async_block_till_done() + + await client.send_json({"id": 2, "type": "repairs/list_issues"}) + msg = await client.receive_json() + assert msg["success"] + assert len(msg["result"]["issues"]) == 1 + assert_issue_repair_in_list( + msg["result"]["issues"], + uuid="1234", + context="addon", + type_="detached_addon_missing", + fixable=False, + placeholders={ + "reference": "test", + "addon": "test", + "addon_url": "https://github.com/home-assistant/addons/test", + }, + ) diff --git a/tests/components/hassio/test_repairs.py b/tests/components/hassio/test_repairs.py index 33d266eb24b..8d0bbfac87c 100644 --- a/tests/components/hassio/test_repairs.py +++ b/tests/components/hassio/test_repairs.py @@ -780,3 +780,90 @@ async def test_supervisor_issue_repair_flow_multiple_data_disks( str(aioclient_mock.mock_calls[-1][1]) == "http://127.0.0.1/resolution/suggestion/1236" ) + + +@pytest.mark.parametrize( + "all_setup_requests", [{"include_addons": True}], indirect=True +) +async def test_supervisor_issue_detached_addon_removed( + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + hass_client: ClientSessionGenerator, + issue_registry: ir.IssueRegistry, + all_setup_requests, +) -> None: + """Test fix flow for supervisor issue.""" + mock_resolution_info( + aioclient_mock, + issues=[ + { + "uuid": "1234", + "type": "detached_addon_removed", + "context": "addon", + "reference": "test", + "suggestions": [ + { + "uuid": "1235", + "type": "execute_remove", + "context": "addon", + "reference": "test", + } + ], + }, + ], + ) + + assert await async_setup_component(hass, "hassio", {}) + + repair_issue = issue_registry.async_get_issue(domain="hassio", issue_id="1234") + assert repair_issue + + client = await hass_client() + + resp = await client.post( + "/api/repairs/issues/fix", + json={"handler": "hassio", "issue_id": repair_issue.issue_id}, + ) + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "form", + "flow_id": flow_id, + "handler": "hassio", + "step_id": "addon_execute_remove", + "data_schema": [], + "errors": None, + "description_placeholders": { + "reference": "test", + "addon": "test", + "help_url": "https://www.home-assistant.io/help/", + "community_url": "https://community.home-assistant.io/", + }, + "last_step": True, + "preview": None, + } + + resp = await client.post(f"/api/repairs/issues/fix/{flow_id}") + + assert resp.status == HTTPStatus.OK + data = await resp.json() + + flow_id = data["flow_id"] + assert data == { + "type": "create_entry", + "flow_id": flow_id, + "handler": "hassio", + "description": None, + "description_placeholders": None, + } + + assert not issue_registry.async_get_issue(domain="hassio", issue_id="1234") + + assert aioclient_mock.mock_calls[-1][0] == "post" + assert ( + str(aioclient_mock.mock_calls[-1][1]) + == "http://127.0.0.1/resolution/suggestion/1235" + ) diff --git a/tests/components/jewish_calendar/conftest.py b/tests/components/jewish_calendar/conftest.py index 5f01ddf8f4a..f7dba01576d 100644 --- a/tests/components/jewish_calendar/conftest.py +++ b/tests/components/jewish_calendar/conftest.py @@ -5,7 +5,7 @@ from unittest.mock import AsyncMock, patch import pytest -from homeassistant.components.jewish_calendar import config_flow +from homeassistant.components.jewish_calendar.const import DEFAULT_NAME, DOMAIN from tests.common import MockConfigEntry @@ -14,8 +14,8 @@ from tests.common import MockConfigEntry def mock_config_entry() -> MockConfigEntry: """Return the default mocked config entry.""" return MockConfigEntry( - title=config_flow.DEFAULT_NAME, - domain=config_flow.DOMAIN, + title=DEFAULT_NAME, + domain=DOMAIN, ) diff --git a/tests/components/jewish_calendar/test_config_flow.py b/tests/components/jewish_calendar/test_config_flow.py index 9d0dec1b83d..ef16742d8d0 100644 --- a/tests/components/jewish_calendar/test_config_flow.py +++ b/tests/components/jewish_calendar/test_config_flow.py @@ -5,11 +5,10 @@ from unittest.mock import AsyncMock import pytest from homeassistant import config_entries, setup -from homeassistant.components.jewish_calendar import ( +from homeassistant.components.jewish_calendar.const import ( CONF_CANDLE_LIGHT_MINUTES, CONF_DIASPORA, CONF_HAVDALAH_OFFSET_MINUTES, - CONF_LANGUAGE, DEFAULT_CANDLE_LIGHT, DEFAULT_DIASPORA, DEFAULT_HAVDALAH_OFFSET_MINUTES, @@ -19,6 +18,7 @@ from homeassistant.components.jewish_calendar import ( from homeassistant.config_entries import SOURCE_USER from homeassistant.const import ( CONF_ELEVATION, + CONF_LANGUAGE, CONF_LATITUDE, CONF_LONGITUDE, CONF_NAME, diff --git a/tests/components/jewish_calendar/test_sensor.py b/tests/components/jewish_calendar/test_sensor.py index 62d5de368d2..4ec132f5e5e 100644 --- a/tests/components/jewish_calendar/test_sensor.py +++ b/tests/components/jewish_calendar/test_sensor.py @@ -4,8 +4,8 @@ from datetime import datetime as dt, timedelta import pytest -from homeassistant.components import jewish_calendar from homeassistant.components.binary_sensor import DOMAIN as SENSOR_DOMAIN +from homeassistant.components.jewish_calendar.const import DOMAIN from homeassistant.core import HomeAssistant from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -17,7 +17,7 @@ from tests.common import MockConfigEntry, async_fire_time_changed async def test_jewish_calendar_min_config(hass: HomeAssistant) -> None: """Test minimum jewish calendar configuration.""" - entry = MockConfigEntry(domain=jewish_calendar.DOMAIN, data={}) + entry = MockConfigEntry(domain=DOMAIN, data={}) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -26,7 +26,7 @@ async def test_jewish_calendar_min_config(hass: HomeAssistant) -> None: async def test_jewish_calendar_hebrew(hass: HomeAssistant) -> None: """Test jewish calendar sensor with language set to hebrew.""" - entry = MockConfigEntry(domain=jewish_calendar.DOMAIN, data={"language": "hebrew"}) + entry = MockConfigEntry(domain=DOMAIN, data={"language": "hebrew"}) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -167,7 +167,7 @@ async def test_jewish_calendar_sensor( with alter_time(test_time): entry = MockConfigEntry( - domain=jewish_calendar.DOMAIN, + domain=DOMAIN, data={ "language": language, "diaspora": diaspora, @@ -509,7 +509,7 @@ async def test_shabbat_times_sensor( with alter_time(test_time): entry = MockConfigEntry( - domain=jewish_calendar.DOMAIN, + domain=DOMAIN, data={ "language": language, "diaspora": diaspora, @@ -566,7 +566,7 @@ async def test_omer_sensor(hass: HomeAssistant, test_time, result) -> None: test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) with alter_time(test_time): - entry = MockConfigEntry(domain=jewish_calendar.DOMAIN) + entry = MockConfigEntry(domain=DOMAIN) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -600,7 +600,7 @@ async def test_dafyomi_sensor(hass: HomeAssistant, test_time, result) -> None: test_time = test_time.replace(tzinfo=dt_util.get_time_zone(hass.config.time_zone)) with alter_time(test_time): - entry = MockConfigEntry(domain=jewish_calendar.DOMAIN) + entry = MockConfigEntry(domain=DOMAIN) entry.add_to_hass(hass) await hass.config_entries.async_setup(entry.entry_id) await hass.async_block_till_done() @@ -620,7 +620,7 @@ async def test_no_discovery_info( assert await async_setup_component( hass, SENSOR_DOMAIN, - {SENSOR_DOMAIN: {"platform": jewish_calendar.DOMAIN}}, + {SENSOR_DOMAIN: {"platform": DOMAIN}}, ) await hass.async_block_till_done() assert SENSOR_DOMAIN in hass.config.components diff --git a/tests/components/mqtt/test_common.py b/tests/components/mqtt/test_common.py index 5d451655307..d196e1998fb 100644 --- a/tests/components/mqtt/test_common.py +++ b/tests/components/mqtt/test_common.py @@ -16,7 +16,7 @@ import yaml from homeassistant import config as module_hass_config from homeassistant.components import mqtt from homeassistant.components.mqtt import debug_info -from homeassistant.components.mqtt.const import MQTT_DISCONNECTED +from homeassistant.components.mqtt.const import MQTT_CONNECTION_STATE from homeassistant.components.mqtt.mixins import MQTT_ATTRIBUTES_BLOCKED from homeassistant.components.mqtt.models import PublishPayloadType from homeassistant.config_entries import ConfigEntryState @@ -115,7 +115,7 @@ async def help_test_availability_when_connection_lost( assert state and state.state != STATE_UNAVAILABLE mqtt_mock.connected = False - async_dispatcher_send(hass, MQTT_DISCONNECTED) + async_dispatcher_send(hass, MQTT_CONNECTION_STATE, False) await hass.async_block_till_done() state = hass.states.get(f"{domain}.test") diff --git a/tests/components/thethingsnetwork/__init__.py b/tests/components/thethingsnetwork/__init__.py new file mode 100644 index 00000000000..be42f1d1f14 --- /dev/null +++ b/tests/components/thethingsnetwork/__init__.py @@ -0,0 +1,10 @@ +"""Define tests for the The Things Network.""" + +from homeassistant.core import HomeAssistant + + +async def init_integration(hass: HomeAssistant, config_entry) -> None: + """Mock TTNClient.""" + config_entry.add_to_hass(hass) + assert await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() diff --git a/tests/components/thethingsnetwork/conftest.py b/tests/components/thethingsnetwork/conftest.py new file mode 100644 index 00000000000..02bec3a0f9e --- /dev/null +++ b/tests/components/thethingsnetwork/conftest.py @@ -0,0 +1,95 @@ +"""Define fixtures for the The Things Network tests.""" + +from unittest.mock import AsyncMock, patch + +import pytest +from ttn_client import TTNSensorValue + +from homeassistant.components.thethingsnetwork.const import ( + CONF_APP_ID, + DOMAIN, + TTN_API_HOST, +) +from homeassistant.const import CONF_API_KEY, CONF_HOST + +from tests.common import MockConfigEntry + +HOST = "example.com" +APP_ID = "my_app" +API_KEY = "my_api_key" + +DEVICE_ID = "my_device" +DEVICE_ID_2 = "my_device_2" +DEVICE_FIELD = "a_field" +DEVICE_FIELD_2 = "a_field_2" +DEVICE_FIELD_VALUE = 42 + +DATA = { + DEVICE_ID: { + DEVICE_FIELD: TTNSensorValue( + { + "end_device_ids": {"device_id": DEVICE_ID}, + "received_at": "2024-03-11T08:49:11.153738893Z", + }, + DEVICE_FIELD, + DEVICE_FIELD_VALUE, + ) + } +} + +DATA_UPDATE = { + DEVICE_ID: { + DEVICE_FIELD: TTNSensorValue( + { + "end_device_ids": {"device_id": DEVICE_ID}, + "received_at": "2024-03-12T08:49:11.153738893Z", + }, + DEVICE_FIELD, + DEVICE_FIELD_VALUE, + ) + }, + DEVICE_ID_2: { + DEVICE_FIELD_2: TTNSensorValue( + { + "end_device_ids": {"device_id": DEVICE_ID_2}, + "received_at": "2024-03-12T08:49:11.153738893Z", + }, + DEVICE_FIELD_2, + DEVICE_FIELD_VALUE, + ) + }, +} + + +@pytest.fixture +def mock_config_entry() -> MockConfigEntry: + """Mock a config entry.""" + return MockConfigEntry( + domain=DOMAIN, + unique_id=APP_ID, + title=APP_ID, + data={ + CONF_APP_ID: APP_ID, + CONF_HOST: TTN_API_HOST, + CONF_API_KEY: API_KEY, + }, + ) + + +@pytest.fixture +def mock_ttnclient(): + """Mock TTNClient.""" + + with ( + patch( + "homeassistant.components.thethingsnetwork.coordinator.TTNClient", + autospec=True, + ) as ttn_client, + patch( + "homeassistant.components.thethingsnetwork.config_flow.TTNClient", + new=ttn_client, + ), + ): + instance = ttn_client.return_value + instance.fetch_data = AsyncMock(return_value=DATA) + yield ttn_client diff --git a/tests/components/thethingsnetwork/test_config_flow.py b/tests/components/thethingsnetwork/test_config_flow.py new file mode 100644 index 00000000000..107d84e099b --- /dev/null +++ b/tests/components/thethingsnetwork/test_config_flow.py @@ -0,0 +1,132 @@ +"""Define tests for the The Things Network onfig flows.""" + +import pytest +from ttn_client import TTNAuthError + +from homeassistant.components.thethingsnetwork.const import CONF_APP_ID, DOMAIN +from homeassistant.config_entries import SOURCE_REAUTH, SOURCE_USER +from homeassistant.const import CONF_API_KEY, CONF_HOST +from homeassistant.core import HomeAssistant +from homeassistant.data_entry_flow import FlowResultType + +from . import init_integration +from .conftest import API_KEY, APP_ID, HOST + +USER_DATA = {CONF_HOST: HOST, CONF_APP_ID: APP_ID, CONF_API_KEY: API_KEY} + + +async def test_user(hass: HomeAssistant, mock_ttnclient) -> None: + """Test user config.""" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=USER_DATA, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + assert result["title"] == APP_ID + assert result["data"][CONF_HOST] == HOST + assert result["data"][CONF_APP_ID] == APP_ID + assert result["data"][CONF_API_KEY] == API_KEY + + +@pytest.mark.parametrize( + ("fetch_data_exception", "base_error"), + [(TTNAuthError, "invalid_auth"), (Exception, "unknown")], +) +async def test_user_errors( + hass: HomeAssistant, fetch_data_exception, base_error, mock_ttnclient +) -> None: + """Test user config errors.""" + + # Test error + mock_ttnclient.return_value.fetch_data.side_effect = fetch_data_exception + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=USER_DATA, + ) + assert result["type"] is FlowResultType.FORM + assert base_error in result["errors"]["base"] + + # Recover + mock_ttnclient.return_value.fetch_data.side_effect = None + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=USER_DATA, + ) + assert result["type"] is FlowResultType.CREATE_ENTRY + + +async def test_duplicate_entry( + hass: HomeAssistant, mock_ttnclient, mock_config_entry +) -> None: + """Test that duplicate entries are caught.""" + + await init_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + ) + + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={"source": SOURCE_USER}, + data=USER_DATA, + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "already_configured" + + +async def test_step_reauth( + hass: HomeAssistant, mock_ttnclient, mock_config_entry +) -> None: + """Test that the reauth step works.""" + + await init_integration(hass, mock_config_entry) + + result = await hass.config_entries.flow.async_init( + DOMAIN, + context={ + "source": SOURCE_REAUTH, + "unique_id": APP_ID, + "entry_id": mock_config_entry.entry_id, + }, + data=USER_DATA, + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "reauth_confirm" + assert not result["errors"] + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input={} + ) + assert result["type"] is FlowResultType.FORM + assert result["step_id"] == "user" + assert not result["errors"] + + new_api_key = "1234" + new_user_input = dict(USER_DATA) + new_user_input[CONF_API_KEY] = new_api_key + + result = await hass.config_entries.flow.async_configure( + result["flow_id"], user_input=new_user_input + ) + assert result["type"] is FlowResultType.ABORT + assert result["reason"] == "reauth_successful" + + assert len(hass.config_entries.async_entries()) == 1 + assert hass.config_entries.async_entries()[0].data[CONF_API_KEY] == new_api_key diff --git a/tests/components/thethingsnetwork/test_init.py b/tests/components/thethingsnetwork/test_init.py new file mode 100644 index 00000000000..1e0b64c933d --- /dev/null +++ b/tests/components/thethingsnetwork/test_init.py @@ -0,0 +1,33 @@ +"""Define tests for the The Things Network init.""" + +import pytest +from ttn_client import TTNAuthError + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import issue_registry as ir +from homeassistant.setup import async_setup_component + +from .conftest import DOMAIN + + +async def test_error_configuration( + hass: HomeAssistant, + issue_registry: ir.IssueRegistry, +) -> None: + """Test issue is logged when deprecated configuration is used.""" + await async_setup_component( + hass, DOMAIN, {DOMAIN: {"app_id": "123", "access_key": "42"}} + ) + await hass.async_block_till_done() + assert issue_registry.async_get_issue(DOMAIN, "manual_migration") + + +@pytest.mark.parametrize(("exception_class"), [TTNAuthError, Exception]) +async def test_init_exceptions( + hass: HomeAssistant, mock_ttnclient, exception_class, mock_config_entry +) -> None: + """Test TTN Exceptions.""" + + mock_ttnclient.return_value.fetch_data.side_effect = exception_class + mock_config_entry.add_to_hass(hass) + assert not await hass.config_entries.async_setup(mock_config_entry.entry_id) diff --git a/tests/components/thethingsnetwork/test_sensor.py b/tests/components/thethingsnetwork/test_sensor.py new file mode 100644 index 00000000000..91583ec6289 --- /dev/null +++ b/tests/components/thethingsnetwork/test_sensor.py @@ -0,0 +1,43 @@ +"""Define tests for the The Things Network sensor.""" + +from homeassistant.core import HomeAssistant +from homeassistant.helpers import device_registry as dr, entity_registry as er + +from . import init_integration +from .conftest import ( + APP_ID, + DATA_UPDATE, + DEVICE_FIELD, + DEVICE_FIELD_2, + DEVICE_ID, + DEVICE_ID_2, + DOMAIN, +) + + +async def test_sensor( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + device_registry: dr.DeviceRegistry, + mock_ttnclient, + mock_config_entry, +) -> None: + """Test a working configurations.""" + + await init_integration(hass, mock_config_entry) + + # Check devices + assert ( + device_registry.async_get_device( + identifiers={(DOMAIN, f"{APP_ID}_{DEVICE_ID}")} + ).name + == DEVICE_ID + ) + + # Check entities + assert entity_registry.async_get(f"sensor.{DEVICE_ID}_{DEVICE_FIELD}") + + assert not entity_registry.async_get(f"sensor.{DEVICE_ID_2}_{DEVICE_FIELD}") + push_callback = mock_ttnclient.call_args.kwargs["push_callback"] + await push_callback(DATA_UPDATE) + assert entity_registry.async_get(f"sensor.{DEVICE_ID_2}_{DEVICE_FIELD_2}") diff --git a/tests/components/unifi/conftest.py b/tests/components/unifi/conftest.py index 938c26b1730..e605599700d 100644 --- a/tests/components/unifi/conftest.py +++ b/tests/components/unifi/conftest.py @@ -3,22 +3,265 @@ from __future__ import annotations import asyncio +from collections.abc import Callable from datetime import timedelta +from types import MappingProxyType +from typing import Any from unittest.mock import patch from aiounifi.models.message import MessageKey import pytest +from homeassistant.components.unifi.const import CONF_SITE_ID, DOMAIN as UNIFI_DOMAIN from homeassistant.components.unifi.hub.websocket import RETRY_TIMER -from homeassistant.const import CONTENT_TYPE_JSON +from homeassistant.config_entries import ConfigEntry +from homeassistant.const import ( + CONF_HOST, + CONF_PASSWORD, + CONF_PORT, + CONF_USERNAME, + CONF_VERIFY_SSL, + CONTENT_TYPE_JSON, +) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr import homeassistant.util.dt as dt_util from tests.common import MockConfigEntry, async_fire_time_changed -from tests.components.unifi.test_hub import DEFAULT_CONFIG_ENTRY_ID from tests.test_util.aiohttp import AiohttpClientMocker +DEFAULT_CONFIG_ENTRY_ID = "1" +DEFAULT_HOST = "1.2.3.4" +DEFAULT_PORT = 1234 +DEFAULT_SITE = "site_id" + + +@pytest.fixture(autouse=True) +def mock_discovery(): + """No real network traffic allowed.""" + with patch( + "homeassistant.components.unifi.config_flow._async_discover_unifi", + return_value=None, + ) as mock: + yield mock + + +@pytest.fixture +def mock_device_registry(hass, device_registry: dr.DeviceRegistry): + """Mock device registry.""" + config_entry = MockConfigEntry(domain="something_else") + config_entry.add_to_hass(hass) + + for idx, device in enumerate( + ( + "00:00:00:00:00:01", + "00:00:00:00:00:02", + "00:00:00:00:00:03", + "00:00:00:00:00:04", + "00:00:00:00:00:05", + "00:00:00:00:00:06", + "00:00:00:00:01:01", + "00:00:00:00:02:02", + ) + ): + device_registry.async_get_or_create( + name=f"Device {idx}", + config_entry_id=config_entry.entry_id, + connections={(dr.CONNECTION_NETWORK_MAC, device)}, + ) + + +# Config entry fixtures + + +@pytest.fixture(name="config_entry") +def config_entry_fixture( + hass: HomeAssistant, + config_entry_data: MappingProxyType[str, Any], + config_entry_options: MappingProxyType[str, Any], +) -> ConfigEntry: + """Define a config entry fixture.""" + config_entry = MockConfigEntry( + domain=UNIFI_DOMAIN, + entry_id="1", + unique_id="1", + data=config_entry_data, + options=config_entry_options, + version=1, + ) + config_entry.add_to_hass(hass) + return config_entry + + +@pytest.fixture(name="config_entry_data") +def config_entry_data_fixture() -> MappingProxyType[str, Any]: + """Define a config entry data fixture.""" + return { + CONF_HOST: DEFAULT_HOST, + CONF_USERNAME: "username", + CONF_PASSWORD: "password", + CONF_PORT: DEFAULT_PORT, + CONF_SITE_ID: DEFAULT_SITE, + CONF_VERIFY_SSL: False, + } + + +@pytest.fixture(name="config_entry_options") +def config_entry_options_fixture() -> MappingProxyType[str, Any]: + """Define a config entry options fixture.""" + return {} + + +@pytest.fixture(name="mock_unifi_requests") +def default_request_fixture( + aioclient_mock: AiohttpClientMocker, + client_payload: list[dict[str, Any]], + clients_all_payload: list[dict[str, Any]], + device_payload: list[dict[str, Any]], + dpi_app_payload: list[dict[str, Any]], + dpi_group_payload: list[dict[str, Any]], + port_forward_payload: list[dict[str, Any]], + site_payload: list[dict[str, Any]], + system_information_payload: list[dict[str, Any]], + wlan_payload: list[dict[str, Any]], +) -> Callable[[str], None]: + """Mock default UniFi requests responses.""" + + def __mock_default_requests(host: str, site_id: str) -> None: + url = f"https://{host}:{DEFAULT_PORT}" + + def mock_get_request(path: str, payload: list[dict[str, Any]]) -> None: + aioclient_mock.get( + f"{url}{path}", + json={"meta": {"rc": "OK"}, "data": payload}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + + aioclient_mock.get(url, status=302) # UniFI OS check + aioclient_mock.post( + f"{url}/api/login", + json={"data": "login successful", "meta": {"rc": "ok"}}, + headers={"content-type": CONTENT_TYPE_JSON}, + ) + mock_get_request("/api/self/sites", site_payload) + mock_get_request(f"/api/s/{site_id}/stat/sta", client_payload) + mock_get_request(f"/api/s/{site_id}/rest/user", clients_all_payload) + mock_get_request(f"/api/s/{site_id}/stat/device", device_payload) + mock_get_request(f"/api/s/{site_id}/rest/dpiapp", dpi_app_payload) + mock_get_request(f"/api/s/{site_id}/rest/dpigroup", dpi_group_payload) + mock_get_request(f"/api/s/{site_id}/rest/portforward", port_forward_payload) + mock_get_request(f"/api/s/{site_id}/stat/sysinfo", system_information_payload) + mock_get_request(f"/api/s/{site_id}/rest/wlanconf", wlan_payload) + + return __mock_default_requests + + +# Request payload fixtures + + +@pytest.fixture(name="client_payload") +def client_data_fixture() -> list[dict[str, Any]]: + """Client data.""" + return [] + + +@pytest.fixture(name="clients_all_payload") +def clients_all_data_fixture() -> list[dict[str, Any]]: + """Clients all data.""" + return [] + + +@pytest.fixture(name="device_payload") +def device_data_fixture() -> list[dict[str, Any]]: + """Device data.""" + return [] + + +@pytest.fixture(name="dpi_app_payload") +def dpi_app_data_fixture() -> list[dict[str, Any]]: + """DPI app data.""" + return [] + + +@pytest.fixture(name="dpi_group_payload") +def dpi_group_data_fixture() -> list[dict[str, Any]]: + """DPI group data.""" + return [] + + +@pytest.fixture(name="port_forward_payload") +def port_forward_data_fixture() -> list[dict[str, Any]]: + """Port forward data.""" + return [] + + +@pytest.fixture(name="site_payload") +def site_data_fixture() -> list[dict[str, Any]]: + """Site data.""" + return [{"desc": "Site name", "name": "site_id", "role": "admin", "_id": "1"}] + + +@pytest.fixture(name="system_information_payload") +def system_information_data_fixture() -> list[dict[str, Any]]: + """System information data.""" + return [ + { + "anonymous_controller_id": "24f81231-a456-4c32-abcd-f5612345385f", + "build": "atag_7.4.162_21057", + "console_display_version": "3.1.15", + "hostname": "UDMP", + "name": "UDMP", + "previous_version": "7.4.156", + "timezone": "Europe/Stockholm", + "ubnt_device_type": "UDMPRO", + "udm_version": "3.0.20.9281", + "update_available": False, + "update_downloaded": False, + "uptime": 1196290, + "version": "7.4.162", + } + ] + + +@pytest.fixture(name="wlan_payload") +def wlan_data_fixture() -> list[dict[str, Any]]: + """WLAN data.""" + return [] + + +@pytest.fixture(name="setup_default_unifi_requests") +def default_vapix_requests_fixture( + config_entry: ConfigEntry, + mock_unifi_requests: Callable[[str, str], None], +) -> None: + """Mock default UniFi requests responses.""" + mock_unifi_requests(config_entry.data[CONF_HOST], config_entry.data[CONF_SITE_ID]) + + +@pytest.fixture(name="prepare_config_entry") +async def prep_config_entry_fixture( + hass: HomeAssistant, config_entry: ConfigEntry, setup_default_unifi_requests: None +) -> Callable[[], ConfigEntry]: + """Fixture factory to set up UniFi network integration.""" + + async def __mock_setup_config_entry() -> ConfigEntry: + await hass.config_entries.async_setup(config_entry.entry_id) + await hass.async_block_till_done() + return config_entry + + return __mock_setup_config_entry + + +@pytest.fixture(name="setup_config_entry") +async def setup_config_entry_fixture( + hass: HomeAssistant, prepare_config_entry: Callable[[], ConfigEntry] +) -> ConfigEntry: + """Fixture to set up UniFi network integration.""" + return await prepare_config_entry() + + +# Websocket fixtures + class WebsocketStateManager(asyncio.Event): """Keep an async event that simules websocket context manager. @@ -97,38 +340,3 @@ def mock_unifi_websocket(hass): raise NotImplementedError return make_websocket_call - - -@pytest.fixture(autouse=True) -def mock_discovery(): - """No real network traffic allowed.""" - with patch( - "homeassistant.components.unifi.config_flow._async_discover_unifi", - return_value=None, - ) as mock: - yield mock - - -@pytest.fixture -def mock_device_registry(hass, device_registry: dr.DeviceRegistry): - """Mock device registry.""" - config_entry = MockConfigEntry(domain="something_else") - config_entry.add_to_hass(hass) - - for idx, device in enumerate( - ( - "00:00:00:00:00:01", - "00:00:00:00:00:02", - "00:00:00:00:00:03", - "00:00:00:00:00:04", - "00:00:00:00:00:05", - "00:00:00:00:00:06", - "00:00:00:00:01:01", - "00:00:00:00:02:02", - ) - ): - device_registry.async_get_or_create( - name=f"Device {idx}", - config_entry_id=config_entry.entry_id, - connections={(dr.CONNECTION_NETWORK_MAC, device)}, - ) diff --git a/tests/components/unifi/test_button.py b/tests/components/unifi/test_button.py index 8f9838e3e37..25fef0fc10b 100644 --- a/tests/components/unifi/test_button.py +++ b/tests/components/unifi/test_button.py @@ -2,6 +2,8 @@ from datetime import timedelta +import pytest + from homeassistant.components.button import DOMAIN as BUTTON_DOMAIN, ButtonDeviceClass from homeassistant.components.unifi.const import CONF_SITE_ID from homeassistant.config_entries import RELOAD_AFTER_UPDATE_DELAY @@ -17,8 +19,6 @@ from homeassistant.helpers import entity_registry as er from homeassistant.helpers.entity_registry import RegistryEntryDisabler import homeassistant.util.dt as dt_util -from .test_hub import setup_unifi_integration - from tests.common import async_fire_time_changed from tests.test_util.aiohttp import AiohttpClientMocker @@ -60,17 +60,10 @@ WLAN = { } -async def test_restart_device_button( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - aioclient_mock: AiohttpClientMocker, - websocket_mock, -) -> None: - """Test restarting device button.""" - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - devices_response=[ +@pytest.mark.parametrize( + "device_payload", + [ + [ { "board_rev": 3, "device_id": "mock-id", @@ -83,8 +76,18 @@ async def test_restart_device_button( "type": "usw", "version": "4.0.42.10433", } - ], - ) + ] + ], +) +async def test_restart_device_button( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + setup_config_entry, + websocket_mock, +) -> None: + """Test restarting device button.""" + config_entry = setup_config_entry assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 1 ent_reg_entry = entity_registry.async_get("button.switch_restart") @@ -127,17 +130,10 @@ async def test_restart_device_button( assert hass.states.get("button.switch_restart").state != STATE_UNAVAILABLE -async def test_power_cycle_poe( - hass: HomeAssistant, - entity_registry: er.EntityRegistry, - aioclient_mock: AiohttpClientMocker, - websocket_mock, -) -> None: - """Test restarting device button.""" - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - devices_response=[ +@pytest.mark.parametrize( + "device_payload", + [ + [ { "board_rev": 3, "device_id": "mock-id", @@ -166,8 +162,18 @@ async def test_power_cycle_poe( }, ], } - ], - ) + ] + ], +) +async def test_power_cycle_poe( + hass: HomeAssistant, + entity_registry: er.EntityRegistry, + aioclient_mock: AiohttpClientMocker, + setup_config_entry, + websocket_mock, +) -> None: + """Test restarting device button.""" + config_entry = setup_config_entry assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 2 ent_reg_entry = entity_registry.async_get("button.switch_port_1_power_cycle") @@ -214,17 +220,16 @@ async def test_power_cycle_poe( ) +@pytest.mark.parametrize("wlan_payload", [[WLAN]]) async def test_wlan_regenerate_password( hass: HomeAssistant, entity_registry: er.EntityRegistry, aioclient_mock: AiohttpClientMocker, + setup_config_entry, websocket_mock, ) -> None: """Test WLAN regenerate password button.""" - - config_entry = await setup_unifi_integration( - hass, aioclient_mock, wlans_response=[WLAN] - ) + config_entry = setup_config_entry assert len(hass.states.async_entity_ids(BUTTON_DOMAIN)) == 0 button_regenerate_password = "button.ssid_1_regenerate_password" diff --git a/tests/components/unifi/test_hub.py b/tests/components/unifi/test_hub.py index 579c39daa4f..b39ba1915e6 100644 --- a/tests/components/unifi/test_hub.py +++ b/tests/components/unifi/test_hub.py @@ -1,9 +1,10 @@ """Test UniFi Network.""" +from collections.abc import Callable from copy import deepcopy from datetime import timedelta from http import HTTPStatus -from unittest.mock import Mock, patch +from unittest.mock import patch import aiounifi import pytest @@ -15,8 +16,6 @@ from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN from homeassistant.components.switch import DOMAIN as SWITCH_DOMAIN from homeassistant.components.unifi.const import ( CONF_SITE_ID, - CONF_TRACK_CLIENTS, - CONF_TRACK_DEVICES, DEFAULT_ALLOW_BANDWIDTH_SENSORS, DEFAULT_ALLOW_UPTIME_SENSORS, DEFAULT_DETECTION_TIME, @@ -29,6 +28,7 @@ from homeassistant.components.unifi.const import ( from homeassistant.components.unifi.errors import AuthenticationRequired, CannotConnect from homeassistant.components.unifi.hub import get_unifi_api from homeassistant.components.update import DOMAIN as UPDATE_DOMAIN +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.const import ( CONF_HOST, CONF_PASSWORD, @@ -39,7 +39,6 @@ from homeassistant.const import ( ) from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr -from homeassistant.helpers.dispatcher import async_dispatcher_connect from homeassistant.setup import async_setup_component import homeassistant.util.dt as dt_util @@ -239,18 +238,15 @@ async def setup_unifi_integration( async def test_hub_setup( - hass: HomeAssistant, device_registry: dr.DeviceRegistry, - aioclient_mock: AiohttpClientMocker, + prepare_config_entry: Callable[[], ConfigEntry], ) -> None: """Successful setup.""" with patch( "homeassistant.config_entries.ConfigEntries.async_forward_entry_setups", return_value=True, ) as forward_entry_setup: - config_entry = await setup_unifi_integration( - hass, aioclient_mock, system_information_response=SYSTEM_INFORMATION - ) + config_entry = await prepare_config_entry() hub = config_entry.runtime_data entry = hub.config.entry @@ -291,109 +287,53 @@ async def test_hub_setup( assert device_entry.sw_version == "7.4.162" -async def test_hub_not_accessible(hass: HomeAssistant) -> None: - """Retry to login gets scheduled when connection fails.""" - with patch( - "homeassistant.components.unifi.hub.get_unifi_api", - side_effect=CannotConnect, - ): - await setup_unifi_integration(hass) - assert hass.data[UNIFI_DOMAIN] == {} - - -async def test_hub_trigger_reauth_flow(hass: HomeAssistant) -> None: - """Failed authentication trigger a reauthentication flow.""" - with ( - patch( - "homeassistant.components.unifi.get_unifi_api", - side_effect=AuthenticationRequired, - ), - patch.object(hass.config_entries.flow, "async_init") as mock_flow_init, - ): - await setup_unifi_integration(hass) - mock_flow_init.assert_called_once() - assert hass.data[UNIFI_DOMAIN] == {} - - -async def test_hub_unknown_error(hass: HomeAssistant) -> None: - """Unknown errors are handled.""" - with patch( - "homeassistant.components.unifi.hub.get_unifi_api", - side_effect=Exception, - ): - await setup_unifi_integration(hass) - assert hass.data[UNIFI_DOMAIN] == {} - - -async def test_config_entry_updated( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker -) -> None: - """Calling reset when the entry has been setup.""" - config_entry = await setup_unifi_integration(hass, aioclient_mock) - hub = config_entry.runtime_data - - event_call = Mock() - unsub = async_dispatcher_connect(hass, hub.signal_options_update, event_call) - - hass.config_entries.async_update_entry( - config_entry, options={CONF_TRACK_CLIENTS: False, CONF_TRACK_DEVICES: False} - ) - await hass.async_block_till_done() - - assert config_entry.options[CONF_TRACK_CLIENTS] is False - assert config_entry.options[CONF_TRACK_DEVICES] is False - - event_call.assert_called_once() - - unsub() - - async def test_reset_after_successful_setup( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, setup_config_entry: ConfigEntry ) -> None: """Calling reset when the entry has been setup.""" - config_entry = await setup_unifi_integration(hass, aioclient_mock) - hub = config_entry.runtime_data + config_entry = setup_config_entry + assert config_entry.state is ConfigEntryState.LOADED - result = await hub.async_reset() - await hass.async_block_till_done() - - assert result is True + assert await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.NOT_LOADED async def test_reset_fails( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker + hass: HomeAssistant, setup_config_entry: ConfigEntry ) -> None: """Calling reset when the entry has been setup can return false.""" - config_entry = await setup_unifi_integration(hass, aioclient_mock) - hub = config_entry.runtime_data + config_entry = setup_config_entry + assert config_entry.state is ConfigEntryState.LOADED with patch( "homeassistant.config_entries.ConfigEntries.async_forward_entry_unload", return_value=False, ): - result = await hub.async_reset() - await hass.async_block_till_done() - - assert result is False + assert not await hass.config_entries.async_unload(config_entry.entry_id) + assert config_entry.state is ConfigEntryState.LOADED +@pytest.mark.parametrize( + "client_payload", + [ + [ + { + "hostname": "client", + "ip": "10.0.0.1", + "is_wired": True, + "last_seen": dt_util.as_timestamp(dt_util.utcnow()), + "mac": "00:00:00:00:00:01", + }, + ] + ], +) async def test_connection_state_signalling( hass: HomeAssistant, - aioclient_mock: AiohttpClientMocker, mock_device_registry, + setup_config_entry: ConfigEntry, websocket_mock, ) -> None: """Verify connection statesignalling and connection state are working.""" - client = { - "hostname": "client", - "ip": "10.0.0.1", - "is_wired": True, - "last_seen": dt_util.as_timestamp(dt_util.utcnow()), - "mac": "00:00:00:00:00:01", - } - await setup_unifi_integration(hass, aioclient_mock, clients_response=[client]) - # Controller is connected assert hass.states.get("device_tracker.client").state == "home" @@ -407,11 +347,12 @@ async def test_connection_state_signalling( async def test_reconnect_mechanism( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + setup_config_entry: ConfigEntry, + websocket_mock, ) -> None: """Verify reconnect prints only on first reconnection try.""" - await setup_unifi_integration(hass, aioclient_mock) - aioclient_mock.clear_requests() aioclient_mock.get(f"https://{DEFAULT_HOST}:1234/", status=HTTPStatus.BAD_GATEWAY) @@ -435,11 +376,13 @@ async def test_reconnect_mechanism( ], ) async def test_reconnect_mechanism_exceptions( - hass: HomeAssistant, aioclient_mock: AiohttpClientMocker, websocket_mock, exception + hass: HomeAssistant, + aioclient_mock: AiohttpClientMocker, + setup_config_entry: ConfigEntry, + websocket_mock, + exception, ) -> None: """Verify async_reconnect calls expected methods.""" - await setup_unifi_integration(hass, aioclient_mock) - with ( patch("aiounifi.Controller.login", side_effect=exception), patch( @@ -452,20 +395,6 @@ async def test_reconnect_mechanism_exceptions( mock_reconnect.assert_called_once() -async def test_get_unifi_api(hass: HomeAssistant) -> None: - """Successful call.""" - with patch("aiounifi.Controller.login", return_value=True): - assert await get_unifi_api(hass, ENTRY_CONFIG) - - -async def test_get_unifi_api_verify_ssl_false(hass: HomeAssistant) -> None: - """Successful call with verify ssl set to false.""" - hub_data = dict(ENTRY_CONFIG) - hub_data[CONF_VERIFY_SSL] = False - with patch("aiounifi.Controller.login", return_value=True): - assert await get_unifi_api(hass, hub_data) - - @pytest.mark.parametrize( ("side_effect", "raised_exception"), [ diff --git a/tests/components/unifi/test_init.py b/tests/components/unifi/test_init.py index 323211272e7..654635ef59f 100644 --- a/tests/components/unifi/test_init.py +++ b/tests/components/unifi/test_init.py @@ -1,9 +1,11 @@ """Test UniFi Network integration setup process.""" +from collections.abc import Callable from typing import Any from unittest.mock import patch from aiounifi.models.message import MessageKey +import pytest from homeassistant.components import unifi from homeassistant.components.unifi.const import ( @@ -14,11 +16,12 @@ from homeassistant.components.unifi.const import ( DOMAIN as UNIFI_DOMAIN, ) from homeassistant.components.unifi.errors import AuthenticationRequired, CannotConnect +from homeassistant.config_entries import ConfigEntry, ConfigEntryState from homeassistant.core import HomeAssistant from homeassistant.helpers import device_registry as dr from homeassistant.setup import async_setup_component -from .test_hub import DEFAULT_CONFIG_ENTRY_ID, setup_unifi_integration +from .test_hub import DEFAULT_CONFIG_ENTRY_ID from tests.common import flush_store from tests.test_util.aiohttp import AiohttpClientMocker @@ -31,18 +34,22 @@ async def test_setup_with_no_config(hass: HomeAssistant) -> None: assert UNIFI_DOMAIN not in hass.data -async def test_setup_entry_fails_config_entry_not_ready(hass: HomeAssistant) -> None: +async def test_setup_entry_fails_config_entry_not_ready( + hass: HomeAssistant, prepare_config_entry: Callable[[], ConfigEntry] +) -> None: """Failed authentication trigger a reauthentication flow.""" with patch( "homeassistant.components.unifi.get_unifi_api", side_effect=CannotConnect, ): - await setup_unifi_integration(hass) + config_entry = await prepare_config_entry() - assert hass.data[UNIFI_DOMAIN] == {} + assert config_entry.state == ConfigEntryState.SETUP_RETRY -async def test_setup_entry_fails_trigger_reauth_flow(hass: HomeAssistant) -> None: +async def test_setup_entry_fails_trigger_reauth_flow( + hass: HomeAssistant, prepare_config_entry: Callable[[], ConfigEntry] +) -> None: """Failed authentication trigger a reauthentication flow.""" with ( patch( @@ -51,16 +58,35 @@ async def test_setup_entry_fails_trigger_reauth_flow(hass: HomeAssistant) -> Non ), patch.object(hass.config_entries.flow, "async_init") as mock_flow_init, ): - await setup_unifi_integration(hass) + config_entry = await prepare_config_entry() mock_flow_init.assert_called_once() - assert hass.data[UNIFI_DOMAIN] == {} + assert config_entry.state == ConfigEntryState.SETUP_ERROR +@pytest.mark.parametrize( + "client_payload", + [ + [ + { + "hostname": "client_1", + "ip": "10.0.0.1", + "is_wired": False, + "mac": "00:00:00:00:00:01", + }, + { + "hostname": "client_2", + "ip": "10.0.0.2", + "is_wired": False, + "mac": "00:00:00:00:00:02", + }, + ] + ], +) async def test_wireless_clients( hass: HomeAssistant, hass_storage: dict[str, Any], - aioclient_mock: AiohttpClientMocker, + prepare_config_entry: Callable[[], ConfigEntry], ) -> None: """Verify wireless clients class.""" hass_storage[unifi.STORAGE_KEY] = { @@ -72,21 +98,7 @@ async def test_wireless_clients( }, } - client_1 = { - "hostname": "client_1", - "ip": "10.0.0.1", - "is_wired": False, - "mac": "00:00:00:00:00:01", - } - client_2 = { - "hostname": "client_2", - "ip": "10.0.0.2", - "is_wired": False, - "mac": "00:00:00:00:00:02", - } - await setup_unifi_integration( - hass, aioclient_mock, clients_response=[client_1, client_2] - ) + await prepare_config_entry() await flush_store(hass.data[unifi.UNIFI_WIRELESS_CLIENTS]._store) assert sorted(hass_storage[unifi.STORAGE_KEY]["data"]["wireless_clients"]) == [ @@ -96,98 +108,113 @@ async def test_wireless_clients( ] +@pytest.mark.parametrize( + "client_payload", + [ + [ + { + "hostname": "Wired client", + "is_wired": True, + "mac": "00:00:00:00:00:01", + "oui": "Producer", + "wired-rx_bytes": 1234000000, + "wired-tx_bytes": 5678000000, + "uptime": 1600094505, + }, + { + "is_wired": False, + "mac": "00:00:00:00:00:02", + "name": "Wireless client", + "oui": "Producer", + "rx_bytes": 2345000000, + "tx_bytes": 6789000000, + "uptime": 60, + }, + ] + ], +) +@pytest.mark.parametrize( + "device_payload", + [ + [ + { + "board_rev": 3, + "device_id": "mock-id", + "has_fan": True, + "fan_level": 0, + "ip": "10.0.1.1", + "last_seen": 1562600145, + "mac": "00:00:00:00:01:01", + "model": "US16P150", + "name": "Device 1", + "next_interval": 20, + "overheating": True, + "state": 1, + "type": "usw", + "upgradable": True, + "version": "4.0.42.10433", + } + ] + ], +) +@pytest.mark.parametrize( + "config_entry_options", + [ + { + CONF_ALLOW_BANDWIDTH_SENSORS: True, + CONF_ALLOW_UPTIME_SENSORS: True, + CONF_TRACK_CLIENTS: True, + CONF_TRACK_DEVICES: True, + } + ], +) async def test_remove_config_entry_device( hass: HomeAssistant, hass_storage: dict[str, Any], aioclient_mock: AiohttpClientMocker, device_registry: dr.DeviceRegistry, + prepare_config_entry: Callable[[], ConfigEntry], + client_payload: list[dict[str, Any]], + device_payload: list[dict[str, Any]], mock_unifi_websocket, hass_ws_client: WebSocketGenerator, ) -> None: """Verify removing a device manually.""" - client_1 = { - "hostname": "Wired client", - "is_wired": True, - "mac": "00:00:00:00:00:01", - "oui": "Producer", - "wired-rx_bytes": 1234000000, - "wired-tx_bytes": 5678000000, - "uptime": 1600094505, - } - client_2 = { - "is_wired": False, - "mac": "00:00:00:00:00:02", - "name": "Wireless client", - "oui": "Producer", - "rx_bytes": 2345000000, - "tx_bytes": 6789000000, - "uptime": 60, - } - device_1 = { - "board_rev": 3, - "device_id": "mock-id", - "has_fan": True, - "fan_level": 0, - "ip": "10.0.1.1", - "last_seen": 1562600145, - "mac": "00:00:00:00:01:01", - "model": "US16P150", - "name": "Device 1", - "next_interval": 20, - "overheating": True, - "state": 1, - "type": "usw", - "upgradable": True, - "version": "4.0.42.10433", - } - options = { - CONF_ALLOW_BANDWIDTH_SENSORS: True, - CONF_ALLOW_UPTIME_SENSORS: True, - CONF_TRACK_CLIENTS: True, - CONF_TRACK_DEVICES: True, - } - - config_entry = await setup_unifi_integration( - hass, - aioclient_mock, - options=options, - clients_response=[client_1, client_2], - devices_response=[device_1], - ) + config_entry = await prepare_config_entry() assert await async_setup_component(hass, "config", {}) ws_client = await hass_ws_client(hass) # Try to remove an active client from UI: not allowed device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, client_1["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, client_payload[0]["mac"])} ) response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) assert not response["success"] assert device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, client_1["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, client_payload[0]["mac"])} ) # Try to remove an active device from UI: not allowed device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, device_1["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, device_payload[0]["mac"])} ) response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) assert not response["success"] assert device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, device_1["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, device_payload[0]["mac"])} ) # Remove a client from Unifi API - mock_unifi_websocket(message=MessageKey.CLIENT_REMOVED, data=[client_2]) + mock_unifi_websocket(message=MessageKey.CLIENT_REMOVED, data=[client_payload[1]]) await hass.async_block_till_done() # Try to remove an inactive client from UI: allowed device_entry = device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, client_2["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, client_payload[1]["mac"])} ) response = await ws_client.remove_device(device_entry.id, config_entry.entry_id) assert response["success"] assert not device_registry.async_get_device( - connections={(dr.CONNECTION_NETWORK_MAC, client_2["mac"])} + connections={(dr.CONNECTION_NETWORK_MAC, client_payload[1]["mac"])} ) diff --git a/tests/conftest.py b/tests/conftest.py index c8309ec6b50..7184456e296 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1023,7 +1023,7 @@ async def _mqtt_mock_entry( mock_mqtt_instance.connected = True mqtt_client_mock.on_connect(mqtt_client_mock, None, 0, 0, 0) - async_dispatcher_send(hass, mqtt.MQTT_CONNECTED) + async_dispatcher_send(hass, mqtt.MQTT_CONNECTION_STATE, True) await hass.async_block_till_done() return mock_mqtt_instance