Merge branch 'dev' into jbouwh-mqtt-device-discovery

This commit is contained in:
J. Nick Koston
2024-05-26 10:24:37 -10:00
committed by GitHub
96 changed files with 1960 additions and 1411 deletions

View File

@@ -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

View File

@@ -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.*

View File

@@ -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

View File

@@ -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)

View File

@@ -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

View File

@@ -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(
(

View File

@@ -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": {

View File

@@ -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")

View File

@@ -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"

View File

@@ -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__)

View File

@@ -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,

View File

@@ -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)

View File

@@ -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": {

View File

@@ -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"]
}

View File

@@ -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]

View File

@@ -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)

View File

@@ -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"),

View File

@@ -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"

View File

@@ -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__)

View File

@@ -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:

View File

@@ -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:

View File

@@ -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:

View File

@@ -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."""

View File

@@ -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:

View File

@@ -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],

View File

@@ -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."""

View File

@@ -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"

View File

@@ -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:

View File

@@ -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

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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:

View File

@@ -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:

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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",
},
)

View File

@@ -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",
},
)

View File

@@ -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:

View File

@@ -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,

View File

@@ -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."""

View File

@@ -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."""

View File

@@ -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."""

View File

@@ -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."""

View File

@@ -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:

View File

@@ -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."""

View File

@@ -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."""

View File

@@ -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)

View File

@@ -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)

View File

@@ -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):

View File

@@ -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:

View File

@@ -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:

View File

@@ -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."""

View File

@@ -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 "

View File

@@ -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(
(

View File

@@ -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)

View File

@@ -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

View File

@@ -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()

View File

@@ -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

View File

@@ -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)

View File

@@ -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)

View File

@@ -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"]
}

View File

@@ -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

View File

@@ -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"
}
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -7,5 +7,5 @@
"iot_class": "local_polling",
"loggers": ["holidays"],
"quality_scale": "internal",
"requirements": ["holidays==0.48"]
"requirements": ["holidays==0.49"]
}

View File

@@ -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

View File

@@ -551,6 +551,7 @@ FLOWS = {
"tessie",
"thermobeacon",
"thermopro",
"thethingsnetwork",
"thread",
"tibber",
"tile",

View File

@@ -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",

View File

@@ -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()

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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")

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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",
},
)

View File

@@ -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"
)

View File

@@ -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,
)

View File

@@ -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,

View File

@@ -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

View File

@@ -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")

View File

@@ -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()

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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}")

View File

@@ -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)},
)

View File

@@ -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"

View File

@@ -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"),
[

View File

@@ -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"])}
)

View File

@@ -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