mirror of
https://github.com/home-assistant/core.git
synced 2026-05-05 20:34:52 +02:00
Compare commits
33 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 03f085d7be | |||
| b3348c3e6f | |||
| 590db0fa74 | |||
| f56ccf90d9 | |||
| c63f8e714e | |||
| a20771f571 | |||
| 2d482f1f57 | |||
| 499962f4ee | |||
| 88a407361c | |||
| 89dc6db5a7 | |||
| de9e7e47fe | |||
| ab66664f20 | |||
| e7e2532c68 | |||
| 4bf10c01f0 | |||
| aad1f4b766 | |||
| e32d89215d | |||
| 9478518937 | |||
| 8a99d2a566 | |||
| 38aff23be5 | |||
| 705e68be9e | |||
| 4a319c73ab | |||
| 576780be74 | |||
| 01734c0dab | |||
| 2157a4d0fc | |||
| b83cb5d1b1 | |||
| 2a627e63f1 | |||
| 30af4c769e | |||
| 02f108498c | |||
| 9f3c0fa927 | |||
| b5811ad1c2 | |||
| baccbd98c7 | |||
| 9d116799d6 | |||
| e877fd6682 |
@@ -28,5 +28,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/august",
|
||||
"iot_class": "cloud_push",
|
||||
"loggers": ["pubnub", "yalexs"],
|
||||
"requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.13"]
|
||||
"requirements": ["yalexs==1.2.7", "yalexs-ble==2.1.14"]
|
||||
}
|
||||
|
||||
@@ -53,7 +53,6 @@ SENSOR_TYPES: dict[str, DevoloBinarySensorEntityDescription] = {
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
icon="mdi:router-network",
|
||||
name="Connected to router",
|
||||
value_func=_is_connected_to_router,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -57,4 +57,5 @@ class DevoloEntity(CoordinatorEntity[DataUpdateCoordinator[_DataT]]):
|
||||
name=entry.title,
|
||||
sw_version=device.firmware_version,
|
||||
)
|
||||
self._attr_translation_key = self.entity_description.key
|
||||
self._attr_unique_id = f"{device.serial_number}_{self.entity_description.key}"
|
||||
|
||||
@@ -54,7 +54,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = {
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
icon="mdi:lan",
|
||||
name="Connected PLC devices",
|
||||
value_func=lambda data: len(
|
||||
{device.mac_address_from for device in data.data_rates}
|
||||
),
|
||||
@@ -62,7 +61,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = {
|
||||
CONNECTED_WIFI_CLIENTS: DevoloSensorEntityDescription[list[ConnectedStationInfo]](
|
||||
key=CONNECTED_WIFI_CLIENTS,
|
||||
icon="mdi:wifi",
|
||||
name="Connected Wifi clients",
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
value_func=len,
|
||||
),
|
||||
@@ -71,7 +69,6 @@ SENSOR_TYPES: dict[str, DevoloSensorEntityDescription[Any]] = {
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
entity_registry_enabled_default=False,
|
||||
icon="mdi:wifi-marker",
|
||||
name="Neighboring Wifi networks",
|
||||
value_func=len,
|
||||
),
|
||||
}
|
||||
|
||||
@@ -27,5 +27,31 @@
|
||||
"home_control": "The devolo Home Control Central Unit does not work with this integration.",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"binary_sensor": {
|
||||
"connected_to_router": {
|
||||
"name": "Connected to router"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"connected_plc_devices": {
|
||||
"name": "Connected PLC devices"
|
||||
},
|
||||
"connected_wifi_clients": {
|
||||
"name": "Connected Wifi clients"
|
||||
},
|
||||
"neighboring_wifi_networks": {
|
||||
"name": "Neighboring Wifi networks"
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"switch_guest_wifi": {
|
||||
"name": "Enable guest Wifi"
|
||||
},
|
||||
"switch_leds": {
|
||||
"name": "Enable LEDs"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = {
|
||||
SWITCH_GUEST_WIFI: DevoloSwitchEntityDescription[WifiGuestAccessGet](
|
||||
key=SWITCH_GUEST_WIFI,
|
||||
icon="mdi:wifi",
|
||||
name="Enable guest Wifi",
|
||||
is_on_func=lambda data: data.enabled is True,
|
||||
turn_on_func=lambda device: device.device.async_set_wifi_guest_access(True), # type: ignore[union-attr]
|
||||
turn_off_func=lambda device: device.device.async_set_wifi_guest_access(False), # type: ignore[union-attr]
|
||||
@@ -51,7 +50,6 @@ SWITCH_TYPES: dict[str, DevoloSwitchEntityDescription[Any]] = {
|
||||
key=SWITCH_LEDS,
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
icon="mdi:led-off",
|
||||
name="Enable LEDs",
|
||||
is_on_func=bool,
|
||||
turn_on_func=lambda device: device.device.async_set_led_setting(True), # type: ignore[union-attr]
|
||||
turn_off_func=lambda device: device.device.async_set_led_setting(False), # type: ignore[union-attr]
|
||||
|
||||
@@ -2,26 +2,26 @@
|
||||
import logging
|
||||
|
||||
from pyezviz.client import EzvizClient
|
||||
from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError
|
||||
from pyezviz.exceptions import (
|
||||
EzvizAuthTokenExpired,
|
||||
EzvizAuthVerificationCode,
|
||||
HTTPError,
|
||||
InvalidURL,
|
||||
PyEzvizError,
|
||||
)
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
CONF_PASSWORD,
|
||||
CONF_TIMEOUT,
|
||||
CONF_TYPE,
|
||||
CONF_URL,
|
||||
CONF_USERNAME,
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.const import CONF_TIMEOUT, CONF_TYPE, CONF_URL, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed, ConfigEntryNotReady
|
||||
|
||||
from .const import (
|
||||
ATTR_TYPE_CAMERA,
|
||||
ATTR_TYPE_CLOUD,
|
||||
CONF_FFMPEG_ARGUMENTS,
|
||||
CONF_RFSESSION_ID,
|
||||
CONF_SESSION_ID,
|
||||
DATA_COORDINATOR,
|
||||
DATA_UNDO_UPDATE_LISTENER,
|
||||
DEFAULT_FFMPEG_ARGUMENTS,
|
||||
DEFAULT_TIMEOUT,
|
||||
DOMAIN,
|
||||
@@ -30,17 +30,22 @@ from .coordinator import EzvizDataUpdateCoordinator
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
|
||||
PLATFORMS = [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CAMERA,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
]
|
||||
PLATFORMS_BY_TYPE: dict[str, list] = {
|
||||
ATTR_TYPE_CAMERA: [],
|
||||
ATTR_TYPE_CLOUD: [
|
||||
Platform.BINARY_SENSOR,
|
||||
Platform.CAMERA,
|
||||
Platform.SENSOR,
|
||||
Platform.SWITCH,
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up EZVIZ from a config entry."""
|
||||
hass.data.setdefault(DOMAIN, {})
|
||||
sensor_type: str = entry.data[CONF_TYPE]
|
||||
ezviz_client = None
|
||||
|
||||
if not entry.options:
|
||||
options = {
|
||||
@@ -50,69 +55,71 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
hass.config_entries.async_update_entry(entry, options=options)
|
||||
|
||||
if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA:
|
||||
if hass.data.get(DOMAIN):
|
||||
# Should only execute on addition of new camera entry.
|
||||
# Fetch Entry id of main account and reload it.
|
||||
for item in hass.config_entries.async_entries():
|
||||
if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD:
|
||||
_LOGGER.info("Reload EZVIZ integration with new camera rtsp entry")
|
||||
await hass.config_entries.async_reload(item.entry_id)
|
||||
# Initialize EZVIZ cloud entities
|
||||
if PLATFORMS_BY_TYPE[sensor_type]:
|
||||
# Initiate reauth config flow if account token if not present.
|
||||
if not entry.data.get(CONF_SESSION_ID):
|
||||
raise ConfigEntryAuthFailed
|
||||
|
||||
return True
|
||||
|
||||
try:
|
||||
ezviz_client = await hass.async_add_executor_job(
|
||||
_get_ezviz_client_instance, entry
|
||||
ezviz_client = EzvizClient(
|
||||
token={
|
||||
CONF_SESSION_ID: entry.data.get(CONF_SESSION_ID),
|
||||
CONF_RFSESSION_ID: entry.data.get(CONF_RFSESSION_ID),
|
||||
"api_url": entry.data.get(CONF_URL),
|
||||
},
|
||||
timeout=entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
|
||||
)
|
||||
except (InvalidURL, HTTPError, PyEzvizError) as error:
|
||||
_LOGGER.error("Unable to connect to EZVIZ service: %s", str(error))
|
||||
raise ConfigEntryNotReady from error
|
||||
|
||||
coordinator = EzvizDataUpdateCoordinator(
|
||||
hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT]
|
||||
try:
|
||||
await hass.async_add_executor_job(ezviz_client.login)
|
||||
|
||||
except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error:
|
||||
raise ConfigEntryAuthFailed from error
|
||||
|
||||
except (InvalidURL, HTTPError, PyEzvizError) as error:
|
||||
_LOGGER.error("Unable to connect to Ezviz service: %s", str(error))
|
||||
raise ConfigEntryNotReady from error
|
||||
|
||||
coordinator = EzvizDataUpdateCoordinator(
|
||||
hass, api=ezviz_client, api_timeout=entry.options[CONF_TIMEOUT]
|
||||
)
|
||||
|
||||
await coordinator.async_config_entry_first_refresh()
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = {DATA_COORDINATOR: coordinator}
|
||||
|
||||
entry.async_on_unload(entry.add_update_listener(_async_update_listener))
|
||||
|
||||
# Check EZVIZ cloud account entity is present, reload cloud account entities for camera entity change to take effect.
|
||||
# Cameras are accessed via local RTSP stream with unique credentials per camera.
|
||||
# Separate camera entities allow for credential changes per camera.
|
||||
if sensor_type == ATTR_TYPE_CAMERA and hass.data[DOMAIN]:
|
||||
for item in hass.config_entries.async_entries(domain=DOMAIN):
|
||||
if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD:
|
||||
_LOGGER.info("Reload Ezviz main account with camera entry")
|
||||
await hass.config_entries.async_reload(item.entry_id)
|
||||
return True
|
||||
|
||||
await hass.config_entries.async_forward_entry_setups(
|
||||
entry, PLATFORMS_BY_TYPE[sensor_type]
|
||||
)
|
||||
await coordinator.async_refresh()
|
||||
|
||||
if not coordinator.last_update_success:
|
||||
raise ConfigEntryNotReady
|
||||
|
||||
undo_listener = entry.add_update_listener(_async_update_listener)
|
||||
|
||||
hass.data[DOMAIN][entry.entry_id] = {
|
||||
DATA_COORDINATOR: coordinator,
|
||||
DATA_UNDO_UPDATE_LISTENER: undo_listener,
|
||||
}
|
||||
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."""
|
||||
sensor_type = entry.data[CONF_TYPE]
|
||||
|
||||
if entry.data.get(CONF_TYPE) == ATTR_TYPE_CAMERA:
|
||||
return True
|
||||
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(entry, PLATFORMS)
|
||||
if unload_ok:
|
||||
hass.data[DOMAIN][entry.entry_id][DATA_UNDO_UPDATE_LISTENER]()
|
||||
unload_ok = await hass.config_entries.async_unload_platforms(
|
||||
entry, PLATFORMS_BY_TYPE[sensor_type]
|
||||
)
|
||||
if sensor_type == ATTR_TYPE_CLOUD and unload_ok:
|
||||
hass.data[DOMAIN].pop(entry.entry_id)
|
||||
|
||||
return unload_ok
|
||||
|
||||
|
||||
async def _async_update_listener(hass: HomeAssistant, entry: ConfigEntry) -> None:
|
||||
"""Handle options update."""
|
||||
await hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
|
||||
def _get_ezviz_client_instance(entry: ConfigEntry) -> EzvizClient:
|
||||
"""Initialize a new instance of EzvizClientApi."""
|
||||
ezviz_client = EzvizClient(
|
||||
entry.data[CONF_USERNAME],
|
||||
entry.data[CONF_PASSWORD],
|
||||
entry.data[CONF_URL],
|
||||
entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
|
||||
)
|
||||
ezviz_client.login()
|
||||
return ezviz_client
|
||||
|
||||
@@ -34,7 +34,6 @@ from .const import (
|
||||
DATA_COORDINATOR,
|
||||
DEFAULT_CAMERA_USERNAME,
|
||||
DEFAULT_FFMPEG_ARGUMENTS,
|
||||
DEFAULT_RTSP_PORT,
|
||||
DIR_DOWN,
|
||||
DIR_LEFT,
|
||||
DIR_RIGHT,
|
||||
@@ -70,24 +69,17 @@ async def async_setup_entry(
|
||||
if item.unique_id == camera and item.source != SOURCE_IGNORE
|
||||
]
|
||||
|
||||
# There seem to be a bug related to localRtspPort in EZVIZ API.
|
||||
local_rtsp_port = (
|
||||
value["local_rtsp_port"]
|
||||
if value["local_rtsp_port"] != 0
|
||||
else DEFAULT_RTSP_PORT
|
||||
)
|
||||
|
||||
if camera_rtsp_entry:
|
||||
ffmpeg_arguments = camera_rtsp_entry[0].options[CONF_FFMPEG_ARGUMENTS]
|
||||
camera_username = camera_rtsp_entry[0].data[CONF_USERNAME]
|
||||
camera_password = camera_rtsp_entry[0].data[CONF_PASSWORD]
|
||||
|
||||
camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{local_rtsp_port}{ffmpeg_arguments}"
|
||||
camera_rtsp_stream = f"rtsp://{camera_username}:{camera_password}@{value['local_ip']}:{value['local_rtsp_port']}{ffmpeg_arguments}"
|
||||
_LOGGER.debug(
|
||||
"Configuring Camera %s with ip: %s rtsp port: %s ffmpeg arguments: %s",
|
||||
camera,
|
||||
value["local_ip"],
|
||||
local_rtsp_port,
|
||||
value["local_rtsp_port"],
|
||||
ffmpeg_arguments,
|
||||
)
|
||||
|
||||
@@ -123,7 +115,7 @@ async def async_setup_entry(
|
||||
camera_username,
|
||||
camera_password,
|
||||
camera_rtsp_stream,
|
||||
local_rtsp_port,
|
||||
value["local_rtsp_port"],
|
||||
ffmpeg_arguments,
|
||||
)
|
||||
)
|
||||
|
||||
@@ -1,12 +1,14 @@
|
||||
"""Config flow for ezviz."""
|
||||
"""Config flow for EZVIZ."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Mapping
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
from pyezviz.client import EzvizClient
|
||||
from pyezviz.exceptions import (
|
||||
AuthTestResultFailed,
|
||||
HTTPError,
|
||||
EzvizAuthVerificationCode,
|
||||
InvalidHost,
|
||||
InvalidURL,
|
||||
PyEzvizError,
|
||||
@@ -25,12 +27,15 @@ from homeassistant.const import (
|
||||
CONF_USERNAME,
|
||||
)
|
||||
from homeassistant.core import callback
|
||||
from homeassistant.data_entry_flow import FlowResult
|
||||
|
||||
from .const import (
|
||||
ATTR_SERIAL,
|
||||
ATTR_TYPE_CAMERA,
|
||||
ATTR_TYPE_CLOUD,
|
||||
CONF_FFMPEG_ARGUMENTS,
|
||||
CONF_RFSESSION_ID,
|
||||
CONF_SESSION_ID,
|
||||
DEFAULT_CAMERA_USERNAME,
|
||||
DEFAULT_FFMPEG_ARGUMENTS,
|
||||
DEFAULT_TIMEOUT,
|
||||
@@ -40,23 +45,37 @@ from .const import (
|
||||
)
|
||||
|
||||
_LOGGER = logging.getLogger(__name__)
|
||||
DEFAULT_OPTIONS = {
|
||||
CONF_FFMPEG_ARGUMENTS: DEFAULT_FFMPEG_ARGUMENTS,
|
||||
CONF_TIMEOUT: DEFAULT_TIMEOUT,
|
||||
}
|
||||
|
||||
|
||||
def _get_ezviz_client_instance(data):
|
||||
"""Initialize a new instance of EzvizClientApi."""
|
||||
def _validate_and_create_auth(data: dict) -> dict[str, Any]:
|
||||
"""Try to login to EZVIZ cloud account and return token."""
|
||||
# Verify cloud credentials by attempting a login request with username and password.
|
||||
# Return login token.
|
||||
|
||||
ezviz_client = EzvizClient(
|
||||
data[CONF_USERNAME],
|
||||
data[CONF_PASSWORD],
|
||||
data.get(CONF_URL, EU_URL),
|
||||
data[CONF_URL],
|
||||
data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
|
||||
)
|
||||
|
||||
ezviz_client.login()
|
||||
return ezviz_client
|
||||
ezviz_token = ezviz_client.login()
|
||||
|
||||
auth_data = {
|
||||
CONF_SESSION_ID: ezviz_token[CONF_SESSION_ID],
|
||||
CONF_RFSESSION_ID: ezviz_token[CONF_RFSESSION_ID],
|
||||
CONF_URL: ezviz_token["api_url"],
|
||||
CONF_TYPE: ATTR_TYPE_CLOUD,
|
||||
}
|
||||
|
||||
return auth_data
|
||||
|
||||
|
||||
def _test_camera_rtsp_creds(data):
|
||||
def _test_camera_rtsp_creds(data: dict) -> None:
|
||||
"""Try DESCRIBE on RTSP camera with credentials."""
|
||||
|
||||
test_rtsp = TestRTSPAuth(
|
||||
@@ -71,89 +90,43 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
VERSION = 1
|
||||
|
||||
async def _validate_and_create_auth(self, data):
|
||||
"""Try to login to ezviz cloud account and create entry if successful."""
|
||||
await self.async_set_unique_id(data[CONF_USERNAME])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
# Verify cloud credentials by attempting a login request.
|
||||
try:
|
||||
await self.hass.async_add_executor_job(_get_ezviz_client_instance, data)
|
||||
|
||||
except InvalidURL as err:
|
||||
raise InvalidURL from err
|
||||
|
||||
except HTTPError as err:
|
||||
raise InvalidHost from err
|
||||
|
||||
except PyEzvizError as err:
|
||||
raise PyEzvizError from err
|
||||
|
||||
auth_data = {
|
||||
CONF_USERNAME: data[CONF_USERNAME],
|
||||
CONF_PASSWORD: data[CONF_PASSWORD],
|
||||
CONF_URL: data.get(CONF_URL, EU_URL),
|
||||
CONF_TYPE: ATTR_TYPE_CLOUD,
|
||||
}
|
||||
|
||||
return self.async_create_entry(title=data[CONF_USERNAME], data=auth_data)
|
||||
|
||||
async def _validate_and_create_camera_rtsp(self, data):
|
||||
async def _validate_and_create_camera_rtsp(self, data: dict) -> FlowResult:
|
||||
"""Try DESCRIBE on RTSP camera with credentials."""
|
||||
|
||||
# Get EZVIZ cloud credentials from config entry
|
||||
ezviz_client_creds = {
|
||||
CONF_USERNAME: None,
|
||||
CONF_PASSWORD: None,
|
||||
CONF_URL: None,
|
||||
ezviz_token = {
|
||||
CONF_SESSION_ID: None,
|
||||
CONF_RFSESSION_ID: None,
|
||||
"api_url": None,
|
||||
}
|
||||
ezviz_timeout = DEFAULT_TIMEOUT
|
||||
|
||||
for item in self._async_current_entries():
|
||||
if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD:
|
||||
ezviz_client_creds = {
|
||||
CONF_USERNAME: item.data.get(CONF_USERNAME),
|
||||
CONF_PASSWORD: item.data.get(CONF_PASSWORD),
|
||||
CONF_URL: item.data.get(CONF_URL),
|
||||
ezviz_token = {
|
||||
CONF_SESSION_ID: item.data.get(CONF_SESSION_ID),
|
||||
CONF_RFSESSION_ID: item.data.get(CONF_RFSESSION_ID),
|
||||
"api_url": item.data.get(CONF_URL),
|
||||
}
|
||||
ezviz_timeout = item.data.get(CONF_TIMEOUT, DEFAULT_TIMEOUT)
|
||||
|
||||
# Abort flow if user removed cloud account before adding camera.
|
||||
if ezviz_client_creds[CONF_USERNAME] is None:
|
||||
if ezviz_token.get(CONF_SESSION_ID) is None:
|
||||
return self.async_abort(reason="ezviz_cloud_account_missing")
|
||||
|
||||
ezviz_client = EzvizClient(token=ezviz_token, timeout=ezviz_timeout)
|
||||
|
||||
# We need to wake hibernating cameras.
|
||||
# First create EZVIZ API instance.
|
||||
try:
|
||||
ezviz_client = await self.hass.async_add_executor_job(
|
||||
_get_ezviz_client_instance, ezviz_client_creds
|
||||
)
|
||||
await self.hass.async_add_executor_job(ezviz_client.login)
|
||||
|
||||
except InvalidURL as err:
|
||||
raise InvalidURL from err
|
||||
|
||||
except HTTPError as err:
|
||||
raise InvalidHost from err
|
||||
|
||||
except PyEzvizError as err:
|
||||
raise PyEzvizError from err
|
||||
|
||||
# Secondly try to wake hibernating camera.
|
||||
try:
|
||||
await self.hass.async_add_executor_job(
|
||||
ezviz_client.get_detection_sensibility, data[ATTR_SERIAL]
|
||||
)
|
||||
|
||||
except HTTPError as err:
|
||||
raise InvalidHost from err
|
||||
# Secondly try to wake hybernating camera.
|
||||
await self.hass.async_add_executor_job(
|
||||
ezviz_client.get_detection_sensibility, data[ATTR_SERIAL]
|
||||
)
|
||||
|
||||
# Thirdly attempts an authenticated RTSP DESCRIBE request.
|
||||
try:
|
||||
await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data)
|
||||
|
||||
except InvalidHost as err:
|
||||
raise InvalidHost from err
|
||||
|
||||
except AuthTestResultFailed as err:
|
||||
raise AuthTestResultFailed from err
|
||||
await self.hass.async_add_executor_job(_test_camera_rtsp_creds, data)
|
||||
|
||||
return self.async_create_entry(
|
||||
title=data[ATTR_SERIAL],
|
||||
@@ -162,6 +135,7 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
CONF_PASSWORD: data[CONF_PASSWORD],
|
||||
CONF_TYPE: ATTR_TYPE_CAMERA,
|
||||
},
|
||||
options=DEFAULT_OPTIONS,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
@@ -170,18 +144,24 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
"""Get the options flow for this handler."""
|
||||
return EzvizOptionsFlowHandler(config_entry)
|
||||
|
||||
async def async_step_user(self, user_input=None):
|
||||
async def async_step_user(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initiated by the user."""
|
||||
|
||||
# Check if ezviz cloud account is present in entry config,
|
||||
# Check if EZVIZ cloud account is present in entry config,
|
||||
# abort if already configured.
|
||||
for item in self._async_current_entries():
|
||||
if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD:
|
||||
return self.async_abort(reason="already_configured_account")
|
||||
|
||||
errors = {}
|
||||
auth_data = {}
|
||||
|
||||
if user_input is not None:
|
||||
await self.async_set_unique_id(user_input[CONF_USERNAME])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
if user_input[CONF_URL] == CONF_CUSTOMIZE:
|
||||
self.context["data"] = {
|
||||
CONF_USERNAME: user_input[CONF_USERNAME],
|
||||
@@ -189,11 +169,10 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
return await self.async_step_user_custom_url()
|
||||
|
||||
if CONF_TIMEOUT not in user_input:
|
||||
user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT
|
||||
|
||||
try:
|
||||
return await self._validate_and_create_auth(user_input)
|
||||
auth_data = await self.hass.async_add_executor_job(
|
||||
_validate_and_create_auth, user_input
|
||||
)
|
||||
|
||||
except InvalidURL:
|
||||
errors["base"] = "invalid_host"
|
||||
@@ -201,6 +180,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except InvalidHost:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
except EzvizAuthVerificationCode:
|
||||
errors["base"] = "mfa_required"
|
||||
|
||||
except PyEzvizError:
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
@@ -208,6 +190,13 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_USERNAME],
|
||||
data=auth_data,
|
||||
options=DEFAULT_OPTIONS,
|
||||
)
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME): str,
|
||||
@@ -222,20 +211,21 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user", data_schema=data_schema, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_user_custom_url(self, user_input=None):
|
||||
async def async_step_user_custom_url(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle a flow initiated by the user for custom region url."""
|
||||
|
||||
errors = {}
|
||||
auth_data = {}
|
||||
|
||||
if user_input is not None:
|
||||
user_input[CONF_USERNAME] = self.context["data"][CONF_USERNAME]
|
||||
user_input[CONF_PASSWORD] = self.context["data"][CONF_PASSWORD]
|
||||
|
||||
if CONF_TIMEOUT not in user_input:
|
||||
user_input[CONF_TIMEOUT] = DEFAULT_TIMEOUT
|
||||
|
||||
try:
|
||||
return await self._validate_and_create_auth(user_input)
|
||||
auth_data = await self.hass.async_add_executor_job(
|
||||
_validate_and_create_auth, user_input
|
||||
)
|
||||
|
||||
except InvalidURL:
|
||||
errors["base"] = "invalid_host"
|
||||
@@ -243,6 +233,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except InvalidHost:
|
||||
errors["base"] = "cannot_connect"
|
||||
|
||||
except EzvizAuthVerificationCode:
|
||||
errors["base"] = "mfa_required"
|
||||
|
||||
except PyEzvizError:
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
@@ -250,6 +243,13 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
else:
|
||||
return self.async_create_entry(
|
||||
title=user_input[CONF_USERNAME],
|
||||
data=auth_data,
|
||||
options=DEFAULT_OPTIONS,
|
||||
)
|
||||
|
||||
data_schema_custom_url = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_URL, default=EU_URL): str,
|
||||
@@ -260,18 +260,22 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
step_id="user_custom_url", data_schema=data_schema_custom_url, errors=errors
|
||||
)
|
||||
|
||||
async def async_step_integration_discovery(self, discovery_info):
|
||||
async def async_step_integration_discovery(
|
||||
self, discovery_info: dict[str, Any]
|
||||
) -> FlowResult:
|
||||
"""Handle a flow for discovered camera without rtsp config entry."""
|
||||
|
||||
await self.async_set_unique_id(discovery_info[ATTR_SERIAL])
|
||||
self._abort_if_unique_id_configured()
|
||||
|
||||
self.context["title_placeholders"] = {"serial": self.unique_id}
|
||||
self.context["title_placeholders"] = {ATTR_SERIAL: self.unique_id}
|
||||
self.context["data"] = {CONF_IP_ADDRESS: discovery_info[CONF_IP_ADDRESS]}
|
||||
|
||||
return await self.async_step_confirm()
|
||||
|
||||
async def async_step_confirm(self, user_input=None):
|
||||
async def async_step_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Confirm and create entry from discovery step."""
|
||||
errors = {}
|
||||
|
||||
@@ -284,6 +288,9 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
except (InvalidHost, InvalidURL):
|
||||
errors["base"] = "invalid_host"
|
||||
|
||||
except EzvizAuthVerificationCode:
|
||||
errors["base"] = "mfa_required"
|
||||
|
||||
except (PyEzvizError, AuthTestResultFailed):
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
@@ -303,11 +310,76 @@ class EzvizConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
data_schema=discovered_camera_schema,
|
||||
errors=errors,
|
||||
description_placeholders={
|
||||
"serial": self.unique_id,
|
||||
ATTR_SERIAL: self.unique_id,
|
||||
CONF_IP_ADDRESS: self.context["data"][CONF_IP_ADDRESS],
|
||||
},
|
||||
)
|
||||
|
||||
async def async_step_reauth(self, user_input: Mapping[str, Any]) -> FlowResult:
|
||||
"""Handle a flow for reauthentication with password."""
|
||||
|
||||
return await self.async_step_reauth_confirm()
|
||||
|
||||
async def async_step_reauth_confirm(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Handle a Confirm flow for reauthentication with password."""
|
||||
auth_data = {}
|
||||
errors = {}
|
||||
entry = None
|
||||
|
||||
for item in self._async_current_entries():
|
||||
if item.data.get(CONF_TYPE) == ATTR_TYPE_CLOUD:
|
||||
self.context["title_placeholders"] = {ATTR_SERIAL: item.title}
|
||||
entry = await self.async_set_unique_id(item.title)
|
||||
|
||||
if not entry:
|
||||
return self.async_abort(reason="ezviz_cloud_account_missing")
|
||||
|
||||
if user_input is not None:
|
||||
user_input[CONF_URL] = entry.data[CONF_URL]
|
||||
|
||||
try:
|
||||
auth_data = await self.hass.async_add_executor_job(
|
||||
_validate_and_create_auth, user_input
|
||||
)
|
||||
|
||||
except (InvalidHost, InvalidURL):
|
||||
errors["base"] = "invalid_host"
|
||||
|
||||
except EzvizAuthVerificationCode:
|
||||
errors["base"] = "mfa_required"
|
||||
|
||||
except (PyEzvizError, AuthTestResultFailed):
|
||||
errors["base"] = "invalid_auth"
|
||||
|
||||
except Exception: # pylint: disable=broad-except
|
||||
_LOGGER.exception("Unexpected exception")
|
||||
return self.async_abort(reason="unknown")
|
||||
|
||||
else:
|
||||
self.hass.config_entries.async_update_entry(
|
||||
entry,
|
||||
data=auth_data,
|
||||
)
|
||||
|
||||
await self.hass.config_entries.async_reload(entry.entry_id)
|
||||
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
data_schema = vol.Schema(
|
||||
{
|
||||
vol.Required(CONF_USERNAME, default=entry.title): vol.In([entry.title]),
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=data_schema,
|
||||
errors=errors,
|
||||
)
|
||||
|
||||
|
||||
class EzvizOptionsFlowHandler(OptionsFlow):
|
||||
"""Handle EZVIZ client options."""
|
||||
@@ -316,22 +388,28 @@ class EzvizOptionsFlowHandler(OptionsFlow):
|
||||
"""Initialize options flow."""
|
||||
self.config_entry = config_entry
|
||||
|
||||
async def async_step_init(self, user_input=None):
|
||||
async def async_step_init(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
) -> FlowResult:
|
||||
"""Manage EZVIZ options."""
|
||||
if user_input is not None:
|
||||
return self.async_create_entry(title="", data=user_input)
|
||||
|
||||
options = {
|
||||
vol.Optional(
|
||||
CONF_TIMEOUT,
|
||||
default=self.config_entry.options.get(CONF_TIMEOUT, DEFAULT_TIMEOUT),
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_FFMPEG_ARGUMENTS,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS
|
||||
),
|
||||
): str,
|
||||
}
|
||||
options = vol.Schema(
|
||||
{
|
||||
vol.Optional(
|
||||
CONF_TIMEOUT,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_TIMEOUT, DEFAULT_TIMEOUT
|
||||
),
|
||||
): int,
|
||||
vol.Optional(
|
||||
CONF_FFMPEG_ARGUMENTS,
|
||||
default=self.config_entry.options.get(
|
||||
CONF_FFMPEG_ARGUMENTS, DEFAULT_FFMPEG_ARGUMENTS
|
||||
),
|
||||
): str,
|
||||
}
|
||||
)
|
||||
|
||||
return self.async_show_form(step_id="init", data_schema=vol.Schema(options))
|
||||
return self.async_show_form(step_id="init", data_schema=options)
|
||||
|
||||
@@ -10,6 +10,9 @@ ATTR_HOME = "HOME_MODE"
|
||||
ATTR_AWAY = "AWAY_MODE"
|
||||
ATTR_TYPE_CLOUD = "EZVIZ_CLOUD_ACCOUNT"
|
||||
ATTR_TYPE_CAMERA = "CAMERA_ACCOUNT"
|
||||
CONF_SESSION_ID = "session_id"
|
||||
CONF_RFSESSION_ID = "rf_session_id"
|
||||
CONF_EZVIZ_ACCOUNT = "ezviz_account"
|
||||
|
||||
# Services data
|
||||
DIR_UP = "up"
|
||||
@@ -33,10 +36,8 @@ SERVICE_DETECTION_SENSITIVITY = "set_alarm_detection_sensibility"
|
||||
EU_URL = "apiieu.ezvizlife.com"
|
||||
RUSSIA_URL = "apirus.ezvizru.com"
|
||||
DEFAULT_CAMERA_USERNAME = "admin"
|
||||
DEFAULT_RTSP_PORT = 554
|
||||
DEFAULT_TIMEOUT = 25
|
||||
DEFAULT_FFMPEG_ARGUMENTS = ""
|
||||
|
||||
# Data
|
||||
DATA_COORDINATOR = "coordinator"
|
||||
DATA_UNDO_UPDATE_LISTENER = "undo_update_listener"
|
||||
|
||||
@@ -4,9 +4,16 @@ import logging
|
||||
|
||||
from async_timeout import timeout
|
||||
from pyezviz.client import EzvizClient
|
||||
from pyezviz.exceptions import HTTPError, InvalidURL, PyEzvizError
|
||||
from pyezviz.exceptions import (
|
||||
EzvizAuthTokenExpired,
|
||||
EzvizAuthVerificationCode,
|
||||
HTTPError,
|
||||
InvalidURL,
|
||||
PyEzvizError,
|
||||
)
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryAuthFailed
|
||||
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator, UpdateFailed
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -27,15 +34,16 @@ class EzvizDataUpdateCoordinator(DataUpdateCoordinator):
|
||||
|
||||
super().__init__(hass, _LOGGER, name=DOMAIN, update_interval=update_interval)
|
||||
|
||||
def _update_data(self) -> dict:
|
||||
"""Fetch data from EZVIZ via camera load function."""
|
||||
return self.ezviz_client.load_cameras()
|
||||
|
||||
async def _async_update_data(self) -> dict:
|
||||
"""Fetch data from EZVIZ."""
|
||||
try:
|
||||
async with timeout(self._api_timeout):
|
||||
return await self.hass.async_add_executor_job(self._update_data)
|
||||
return await self.hass.async_add_executor_job(
|
||||
self.ezviz_client.load_cameras
|
||||
)
|
||||
|
||||
except (EzvizAuthTokenExpired, EzvizAuthVerificationCode) as error:
|
||||
raise ConfigEntryAuthFailed from error
|
||||
|
||||
except (InvalidURL, HTTPError, PyEzvizError) as error:
|
||||
raise UpdateFailed(f"Invalid response from API: {error}") from error
|
||||
|
||||
@@ -26,17 +26,27 @@
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "[%key:common::config_flow::title::reauth%]",
|
||||
"description": "Enter credentials to reauthenticate to ezviz cloud account",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
}
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"cannot_connect": "[%key:common::config_flow::error::cannot_connect%]",
|
||||
"invalid_auth": "[%key:common::config_flow::error::invalid_auth%]",
|
||||
"invalid_host": "[%key:common::config_flow::error::invalid_host%]"
|
||||
"invalid_host": "[%key:common::config_flow::error::invalid_host%]",
|
||||
"mfa_required": "2FA enabled on account, please disable and retry"
|
||||
},
|
||||
"abort": {
|
||||
"already_configured_account": "[%key:common::config_flow::abort::already_configured_account%]",
|
||||
"unknown": "[%key:common::config_flow::error::unknown%]",
|
||||
"ezviz_cloud_account_missing": "EZVIZ cloud account missing. Please reconfigure EZVIZ cloud account"
|
||||
"ezviz_cloud_account_missing": "Ezviz cloud account missing. Please reconfigure Ezviz cloud account",
|
||||
"reauth_successful": "[%key:common::config_flow::abort::reauth_successful%]"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
|
||||
@@ -11,24 +11,19 @@ from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from .const import PLATFORMS
|
||||
|
||||
|
||||
def check_path(path: pathlib.Path) -> bool:
|
||||
"""Check path."""
|
||||
return path.exists() and path.is_file()
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up from a config entry."""
|
||||
|
||||
path = entry.data[CONF_FILE_PATH]
|
||||
def _check_path(hass: HomeAssistant, path: str) -> None:
|
||||
"""Check if path is valid and allowed."""
|
||||
get_path = pathlib.Path(path)
|
||||
|
||||
check_file = await hass.async_add_executor_job(check_path, get_path)
|
||||
if not check_file:
|
||||
if not get_path.exists() or not get_path.is_file():
|
||||
raise ConfigEntryNotReady(f"Can not access file {path}")
|
||||
|
||||
if not hass.config.is_allowed_path(path):
|
||||
raise ConfigEntryNotReady(f"Filepath {path} is not valid or allowed")
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
"""Set up from a config entry."""
|
||||
await hass.async_add_executor_job(_check_path, hass, entry.data[CONF_FILE_PATH])
|
||||
await hass.config_entries.async_forward_entry_setups(entry, PLATFORMS)
|
||||
return True
|
||||
|
||||
|
||||
@@ -49,7 +49,9 @@ class FilesizeConfigFlow(ConfigFlow, domain=DOMAIN):
|
||||
|
||||
if user_input is not None:
|
||||
try:
|
||||
full_path = validate_path(self.hass, user_input[CONF_FILE_PATH])
|
||||
full_path = await self.hass.async_add_executor_job(
|
||||
validate_path, self.hass, user_input[CONF_FILE_PATH]
|
||||
)
|
||||
except NotValidError:
|
||||
errors["base"] = "not_valid"
|
||||
except NotAllowedError:
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20230329.0"]
|
||||
"requirements": ["home-assistant-frontend==20230331.0"]
|
||||
}
|
||||
|
||||
@@ -188,7 +188,9 @@ class ConfigFlow(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
return await self._async_create_entry()
|
||||
|
||||
self._set_confirm_only()
|
||||
return self.async_show_form(step_id="confirm")
|
||||
return self.async_show_form(
|
||||
step_id="confirm", description_placeholders={"name": self._name}
|
||||
)
|
||||
|
||||
async def async_step_device_config(
|
||||
self, user_input: dict[str, Any] | None = None
|
||||
|
||||
@@ -13,6 +13,9 @@
|
||||
"data": {
|
||||
"pin": "[%key:common::config_flow::data::pin%]"
|
||||
}
|
||||
},
|
||||
"confirm": {
|
||||
"description": "Do you want to set up {name}?"
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
|
||||
@@ -36,28 +36,28 @@ class LaMetricButtonEntityDescription(
|
||||
BUTTONS = [
|
||||
LaMetricButtonEntityDescription(
|
||||
key="app_next",
|
||||
name="Next app",
|
||||
translation_key="app_next",
|
||||
icon="mdi:arrow-right-bold",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda api: api.app_next(),
|
||||
),
|
||||
LaMetricButtonEntityDescription(
|
||||
key="app_previous",
|
||||
name="Previous app",
|
||||
translation_key="app_previous",
|
||||
icon="mdi:arrow-left-bold",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda api: api.app_previous(),
|
||||
),
|
||||
LaMetricButtonEntityDescription(
|
||||
key="dismiss_current",
|
||||
name="Dismiss current notification",
|
||||
translation_key="dismiss_current",
|
||||
icon="mdi:bell-cancel",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda api: api.dismiss_current_notification(),
|
||||
),
|
||||
LaMetricButtonEntityDescription(
|
||||
key="dismiss_all",
|
||||
name="Dismiss all notifications",
|
||||
translation_key="dismiss_all",
|
||||
icon="mdi:bell-cancel",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
press_fn=lambda api: api.dismiss_all_notifications(),
|
||||
|
||||
@@ -37,11 +37,10 @@ class LaMetricSelectEntityDescription(
|
||||
SELECTS = [
|
||||
LaMetricSelectEntityDescription(
|
||||
key="brightness_mode",
|
||||
name="Brightness mode",
|
||||
translation_key="brightness_mode",
|
||||
icon="mdi:brightness-auto",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
options=["auto", "manual"],
|
||||
translation_key="brightness_mode",
|
||||
current_fn=lambda device: device.display.brightness_mode.value,
|
||||
select_fn=lambda api, opt: api.display(brightness_mode=BrightnessMode(opt)),
|
||||
),
|
||||
|
||||
@@ -38,6 +38,7 @@ class LaMetricSensorEntityDescription(
|
||||
SENSORS = [
|
||||
LaMetricSensorEntityDescription(
|
||||
key="rssi",
|
||||
translation_key="rssi",
|
||||
name="Wi-Fi signal",
|
||||
icon="mdi:wifi",
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
|
||||
@@ -45,13 +45,38 @@
|
||||
}
|
||||
},
|
||||
"entity": {
|
||||
"button": {
|
||||
"app_next": {
|
||||
"name": "Next app"
|
||||
},
|
||||
"app_previous": {
|
||||
"name": "Previous app"
|
||||
},
|
||||
"dismiss_current": {
|
||||
"name": "Dismiss current notification"
|
||||
},
|
||||
"dismiss_all": {
|
||||
"name": "Dismiss all notifications"
|
||||
}
|
||||
},
|
||||
"sensor": {
|
||||
"rssi": {
|
||||
"name": "Wi-Fi signal"
|
||||
}
|
||||
},
|
||||
"select": {
|
||||
"brightness_mode": {
|
||||
"name": "Brightness mode",
|
||||
"state": {
|
||||
"auto": "Automatic",
|
||||
"manual": "Manual"
|
||||
}
|
||||
}
|
||||
},
|
||||
"switch": {
|
||||
"bluetooth": {
|
||||
"name": "Bluetooth"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -39,7 +39,7 @@ class LaMetricSwitchEntityDescription(
|
||||
SWITCHES = [
|
||||
LaMetricSwitchEntityDescription(
|
||||
key="bluetooth",
|
||||
name="Bluetooth",
|
||||
translation_key="bluetooth",
|
||||
icon="mdi:bluetooth",
|
||||
entity_category=EntityCategory.CONFIG,
|
||||
available_fn=lambda device: device.bluetooth.available,
|
||||
|
||||
@@ -29,6 +29,7 @@ from homeassistant.const import (
|
||||
STATE_ALARM_TRIGGERED,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import async_track_point_in_time
|
||||
@@ -285,56 +286,34 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity):
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send disarm command."""
|
||||
if not self._async_validate_code(code, STATE_ALARM_DISARMED):
|
||||
return
|
||||
|
||||
self._async_validate_code(code, STATE_ALARM_DISARMED)
|
||||
self._state = STATE_ALARM_DISARMED
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send arm home command."""
|
||||
if self.code_arm_required and not self._async_validate_code(
|
||||
code, STATE_ALARM_ARMED_HOME
|
||||
):
|
||||
return
|
||||
|
||||
self._async_validate_code(code, STATE_ALARM_ARMED_HOME)
|
||||
self._async_update_state(STATE_ALARM_ARMED_HOME)
|
||||
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
if self.code_arm_required and not self._async_validate_code(
|
||||
code, STATE_ALARM_ARMED_AWAY
|
||||
):
|
||||
return
|
||||
|
||||
self._async_validate_code(code, STATE_ALARM_ARMED_AWAY)
|
||||
self._async_update_state(STATE_ALARM_ARMED_AWAY)
|
||||
|
||||
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
||||
"""Send arm night command."""
|
||||
if self.code_arm_required and not self._async_validate_code(
|
||||
code, STATE_ALARM_ARMED_NIGHT
|
||||
):
|
||||
return
|
||||
|
||||
self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT)
|
||||
self._async_update_state(STATE_ALARM_ARMED_NIGHT)
|
||||
|
||||
async def async_alarm_arm_vacation(self, code: str | None = None) -> None:
|
||||
"""Send arm vacation command."""
|
||||
if self.code_arm_required and not self._async_validate_code(
|
||||
code, STATE_ALARM_ARMED_VACATION
|
||||
):
|
||||
return
|
||||
|
||||
self._async_validate_code(code, STATE_ALARM_ARMED_VACATION)
|
||||
self._async_update_state(STATE_ALARM_ARMED_VACATION)
|
||||
|
||||
async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None:
|
||||
"""Send arm custom bypass command."""
|
||||
if self.code_arm_required and not self._async_validate_code(
|
||||
code, STATE_ALARM_ARMED_CUSTOM_BYPASS
|
||||
):
|
||||
return
|
||||
|
||||
self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS)
|
||||
self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS)
|
||||
|
||||
async def async_alarm_trigger(self, code: str | None = None) -> None:
|
||||
@@ -383,18 +362,22 @@ class ManualAlarm(alarm.AlarmControlPanelEntity, RestoreEntity):
|
||||
|
||||
def _async_validate_code(self, code, state):
|
||||
"""Validate given code."""
|
||||
if self._code is None:
|
||||
return True
|
||||
if (
|
||||
state != STATE_ALARM_DISARMED and not self.code_arm_required
|
||||
) or self._code is None:
|
||||
return
|
||||
|
||||
if isinstance(self._code, str):
|
||||
alarm_code = self._code
|
||||
else:
|
||||
alarm_code = self._code.async_render(
|
||||
parse_result=False, from_state=self._state, to_state=state
|
||||
)
|
||||
check = not alarm_code or code == alarm_code
|
||||
if not check:
|
||||
_LOGGER.warning("Invalid code given for %s", state)
|
||||
return check
|
||||
|
||||
if not alarm_code or code == alarm_code:
|
||||
return
|
||||
|
||||
raise HomeAssistantError("Invalid alarm code provided")
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
|
||||
@@ -29,6 +29,7 @@ from homeassistant.const import (
|
||||
STATE_ALARM_TRIGGERED,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
import homeassistant.helpers.config_validation as cv
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.event import (
|
||||
@@ -345,56 +346,34 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity):
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send disarm command."""
|
||||
if not self._async_validate_code(code, STATE_ALARM_DISARMED):
|
||||
return
|
||||
|
||||
self._async_validate_code(code, STATE_ALARM_DISARMED)
|
||||
self._state = STATE_ALARM_DISARMED
|
||||
self._state_ts = dt_util.utcnow()
|
||||
self.async_schedule_update_ha_state()
|
||||
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send arm home command."""
|
||||
if self.code_arm_required and not self._async_validate_code(
|
||||
code, STATE_ALARM_ARMED_HOME
|
||||
):
|
||||
return
|
||||
|
||||
self._async_validate_code(code, STATE_ALARM_ARMED_HOME)
|
||||
self._async_update_state(STATE_ALARM_ARMED_HOME)
|
||||
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
if self.code_arm_required and not self._async_validate_code(
|
||||
code, STATE_ALARM_ARMED_AWAY
|
||||
):
|
||||
return
|
||||
|
||||
self._async_validate_code(code, STATE_ALARM_ARMED_AWAY)
|
||||
self._async_update_state(STATE_ALARM_ARMED_AWAY)
|
||||
|
||||
async def async_alarm_arm_night(self, code: str | None = None) -> None:
|
||||
"""Send arm night command."""
|
||||
if self.code_arm_required and not self._async_validate_code(
|
||||
code, STATE_ALARM_ARMED_NIGHT
|
||||
):
|
||||
return
|
||||
|
||||
self._async_validate_code(code, STATE_ALARM_ARMED_NIGHT)
|
||||
self._async_update_state(STATE_ALARM_ARMED_NIGHT)
|
||||
|
||||
async def async_alarm_arm_vacation(self, code: str | None = None) -> None:
|
||||
"""Send arm vacation command."""
|
||||
if self.code_arm_required and not self._async_validate_code(
|
||||
code, STATE_ALARM_ARMED_VACATION
|
||||
):
|
||||
return
|
||||
|
||||
self._async_validate_code(code, STATE_ALARM_ARMED_VACATION)
|
||||
self._async_update_state(STATE_ALARM_ARMED_VACATION)
|
||||
|
||||
async def async_alarm_arm_custom_bypass(self, code: str | None = None) -> None:
|
||||
"""Send arm custom bypass command."""
|
||||
if self.code_arm_required and not self._async_validate_code(
|
||||
code, STATE_ALARM_ARMED_CUSTOM_BYPASS
|
||||
):
|
||||
return
|
||||
|
||||
self._async_validate_code(code, STATE_ALARM_ARMED_CUSTOM_BYPASS)
|
||||
self._async_update_state(STATE_ALARM_ARMED_CUSTOM_BYPASS)
|
||||
|
||||
async def async_alarm_trigger(self, code: str | None = None) -> None:
|
||||
@@ -436,18 +415,22 @@ class ManualMQTTAlarm(alarm.AlarmControlPanelEntity):
|
||||
|
||||
def _async_validate_code(self, code, state):
|
||||
"""Validate given code."""
|
||||
if self._code is None:
|
||||
return True
|
||||
if (
|
||||
state != STATE_ALARM_DISARMED and not self.code_arm_required
|
||||
) or self._code is None:
|
||||
return
|
||||
|
||||
if isinstance(self._code, str):
|
||||
alarm_code = self._code
|
||||
else:
|
||||
alarm_code = self._code.async_render(
|
||||
from_state=self._state, to_state=state, parse_result=False
|
||||
)
|
||||
check = not alarm_code or code == alarm_code
|
||||
if not check:
|
||||
_LOGGER.warning("Invalid code given for %s", state)
|
||||
return check
|
||||
|
||||
if not alarm_code or code == alarm_code:
|
||||
return
|
||||
|
||||
raise HomeAssistantError("Invalid alarm code provided")
|
||||
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
|
||||
@@ -25,6 +25,7 @@ from homeassistant.const import (
|
||||
Platform,
|
||||
)
|
||||
from homeassistant.core import Event, HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady
|
||||
from homeassistant.helpers import device_registry as dr, entity_registry as er
|
||||
from homeassistant.helpers.network import get_url
|
||||
from homeassistant.helpers.update_coordinator import (
|
||||
@@ -146,23 +147,6 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass, DOMAIN, entry.title, entry.entry_id, handle_webhook, local_only=True
|
||||
)
|
||||
|
||||
async def _stop_nuki(_: Event):
|
||||
"""Stop and remove the Nuki webhook."""
|
||||
webhook.async_unregister(hass, entry.entry_id)
|
||||
try:
|
||||
async with async_timeout.timeout(10):
|
||||
await hass.async_add_executor_job(
|
||||
_remove_webhook, bridge, entry.entry_id
|
||||
)
|
||||
except InvalidCredentialsException as err:
|
||||
raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err
|
||||
except RequestException as err:
|
||||
raise UpdateFailed(f"Error communicating with Bridge: {err}") from err
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki)
|
||||
)
|
||||
|
||||
webhook_url = webhook.async_generate_path(entry.entry_id)
|
||||
hass_url = get_url(
|
||||
hass, allow_cloud=False, allow_external=False, allow_ip=True, require_ssl=False
|
||||
@@ -174,9 +158,30 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
_register_webhook, bridge, entry.entry_id, url
|
||||
)
|
||||
except InvalidCredentialsException as err:
|
||||
raise UpdateFailed(f"Invalid credentials for Bridge: {err}") from err
|
||||
webhook.async_unregister(hass, entry.entry_id)
|
||||
raise ConfigEntryNotReady(f"Invalid credentials for Bridge: {err}") from err
|
||||
except RequestException as err:
|
||||
raise UpdateFailed(f"Error communicating with Bridge: {err}") from err
|
||||
webhook.async_unregister(hass, entry.entry_id)
|
||||
raise ConfigEntryNotReady(f"Error communicating with Bridge: {err}") from err
|
||||
|
||||
async def _stop_nuki(_: Event):
|
||||
"""Stop and remove the Nuki webhook."""
|
||||
webhook.async_unregister(hass, entry.entry_id)
|
||||
try:
|
||||
async with async_timeout.timeout(10):
|
||||
await hass.async_add_executor_job(
|
||||
_remove_webhook, bridge, entry.entry_id
|
||||
)
|
||||
except InvalidCredentialsException as err:
|
||||
_LOGGER.error(
|
||||
"Error unregistering webhook, invalid credentials for bridge: %s", err
|
||||
)
|
||||
except RequestException as err:
|
||||
_LOGGER.error("Error communicating with bridge: %s", err)
|
||||
|
||||
entry.async_on_unload(
|
||||
hass.bus.async_listen_once(EVENT_HOMEASSISTANT_STOP, _stop_nuki)
|
||||
)
|
||||
|
||||
coordinator = NukiCoordinator(hass, bridge, locks, openers)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_API_KEY
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import ConfigEntryNotReady, TemplateError
|
||||
from homeassistant.helpers import area_registry as ar, intent, template
|
||||
from homeassistant.helpers import intent, template
|
||||
from homeassistant.util import ulid
|
||||
|
||||
from .const import (
|
||||
@@ -138,7 +138,6 @@ class OpenAIAgent(conversation.AbstractConversationAgent):
|
||||
return template.Template(raw_prompt, self.hass).async_render(
|
||||
{
|
||||
"ha_name": self.hass.config.location_name,
|
||||
"areas": list(ar.async_get(self.hass).areas.values()),
|
||||
},
|
||||
parse_result=False,
|
||||
)
|
||||
|
||||
@@ -5,13 +5,13 @@ CONF_PROMPT = "prompt"
|
||||
DEFAULT_PROMPT = """This smart home is controlled by Home Assistant.
|
||||
|
||||
An overview of the areas and the devices in this smart home:
|
||||
{%- for area in areas %}
|
||||
{%- for area in areas() %}
|
||||
{%- set area_info = namespace(printed=false) %}
|
||||
{%- for device in area_devices(area.name) -%}
|
||||
{%- for device in area_devices(area) -%}
|
||||
{%- if not device_attr(device, "disabled_by") and not device_attr(device, "entry_type") and device_attr(device, "name") %}
|
||||
{%- if not area_info.printed %}
|
||||
|
||||
{{ area.name }}:
|
||||
{{ area_name(area) }}:
|
||||
{%- set area_info.printed = true %}
|
||||
{%- endif %}
|
||||
- {{ device_attr(device, "name") }}{% if device_attr(device, "model") and (device_attr(device, "model") | string) not in (device_attr(device, "name") | string) %} ({{ device_attr(device, "model") }}){% endif %}
|
||||
|
||||
@@ -1355,10 +1355,16 @@ def _context_id_to_bytes(context_id: str | None) -> bytes | None:
|
||||
"""Convert a context_id to bytes."""
|
||||
if context_id is None:
|
||||
return None
|
||||
if len(context_id) == 32:
|
||||
return UUID(context_id).bytes
|
||||
if len(context_id) == 26:
|
||||
return ulid_to_bytes(context_id)
|
||||
with contextlib.suppress(ValueError):
|
||||
# There may be garbage in the context_id column
|
||||
# from custom integrations that are not UUIDs or
|
||||
# ULIDs that filled the column to the max length
|
||||
# so we need to catch the ValueError and return
|
||||
# None if it happens
|
||||
if len(context_id) == 32:
|
||||
return UUID(context_id).bytes
|
||||
if len(context_id) == 26:
|
||||
return ulid_to_bytes(context_id)
|
||||
return None
|
||||
|
||||
|
||||
@@ -1439,12 +1445,15 @@ def migrate_event_type_ids(instance: Recorder) -> bool:
|
||||
with session_scope(session=session_maker()) as session:
|
||||
if events := session.execute(find_event_type_to_migrate()).all():
|
||||
event_types = {event_type for _, event_type in events}
|
||||
if None in event_types:
|
||||
# event_type should never be None but we need to be defensive
|
||||
# so we don't fail the migration because of a bad state
|
||||
event_types.remove(None)
|
||||
event_types.add(_EMPTY_EVENT_TYPE)
|
||||
|
||||
event_type_to_id = event_type_manager.get_many(event_types, session)
|
||||
if missing_event_types := {
|
||||
# We should never see see None for the event_Type in the events table
|
||||
# but we need to be defensive so we don't fail the migration
|
||||
# because of a bad event
|
||||
_EMPTY_EVENT_TYPE if event_type is None else event_type
|
||||
event_type
|
||||
for event_type, event_id in event_type_to_id.items()
|
||||
if event_id is None
|
||||
}:
|
||||
@@ -1470,7 +1479,9 @@ def migrate_event_type_ids(instance: Recorder) -> bool:
|
||||
{
|
||||
"event_id": event_id,
|
||||
"event_type": None,
|
||||
"event_type_id": event_type_to_id[event_type],
|
||||
"event_type_id": event_type_to_id[
|
||||
_EMPTY_EVENT_TYPE if event_type is None else event_type
|
||||
],
|
||||
}
|
||||
for event_id, event_type in events
|
||||
],
|
||||
@@ -1502,14 +1513,17 @@ def migrate_entity_ids(instance: Recorder) -> bool:
|
||||
with session_scope(session=instance.get_session()) as session:
|
||||
if states := session.execute(find_entity_ids_to_migrate()).all():
|
||||
entity_ids = {entity_id for _, entity_id in states}
|
||||
if None in entity_ids:
|
||||
# entity_id should never be None but we need to be defensive
|
||||
# so we don't fail the migration because of a bad state
|
||||
entity_ids.remove(None)
|
||||
entity_ids.add(_EMPTY_ENTITY_ID)
|
||||
|
||||
entity_id_to_metadata_id = states_meta_manager.get_many(
|
||||
entity_ids, session, True
|
||||
)
|
||||
if missing_entity_ids := {
|
||||
# We should never see _EMPTY_ENTITY_ID in the states table
|
||||
# but we need to be defensive so we don't fail the migration
|
||||
# because of a bad state
|
||||
_EMPTY_ENTITY_ID if entity_id is None else entity_id
|
||||
entity_id
|
||||
for entity_id, metadata_id in entity_id_to_metadata_id.items()
|
||||
if metadata_id is None
|
||||
}:
|
||||
@@ -1537,7 +1551,9 @@ def migrate_entity_ids(instance: Recorder) -> bool:
|
||||
# the history queries still need to work while the
|
||||
# migration is in progress and we will do this in
|
||||
# post_migrate_entity_ids
|
||||
"metadata_id": entity_id_to_metadata_id[entity_id],
|
||||
"metadata_id": entity_id_to_metadata_id[
|
||||
_EMPTY_ENTITY_ID if entity_id is None else entity_id
|
||||
],
|
||||
}
|
||||
for state_id, entity_id in states
|
||||
],
|
||||
|
||||
@@ -18,5 +18,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/reolink",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"requirements": ["reolink-aio==0.5.7"]
|
||||
"requirements": ["reolink-aio==0.5.9"]
|
||||
}
|
||||
|
||||
@@ -95,6 +95,8 @@ RESOURCE_SETUP = {
|
||||
vol.Optional(CONF_ENCODING, default=DEFAULT_ENCODING): TextSelector(),
|
||||
}
|
||||
|
||||
NONE_SENTINEL = "none"
|
||||
|
||||
SENSOR_SETUP = {
|
||||
vol.Required(CONF_SELECT): TextSelector(),
|
||||
vol.Optional(CONF_INDEX, default=0): NumberSelector(
|
||||
@@ -102,28 +104,45 @@ SENSOR_SETUP = {
|
||||
),
|
||||
vol.Optional(CONF_ATTRIBUTE): TextSelector(),
|
||||
vol.Optional(CONF_VALUE_TEMPLATE): TemplateSelector(),
|
||||
vol.Optional(CONF_DEVICE_CLASS): SelectSelector(
|
||||
vol.Required(CONF_DEVICE_CLASS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[cls.value for cls in SensorDeviceClass],
|
||||
options=[NONE_SENTINEL]
|
||||
+ sorted(
|
||||
[
|
||||
cls.value
|
||||
for cls in SensorDeviceClass
|
||||
if cls != SensorDeviceClass.ENUM
|
||||
]
|
||||
),
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="device_class",
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_STATE_CLASS): SelectSelector(
|
||||
vol.Required(CONF_STATE_CLASS): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[cls.value for cls in SensorStateClass],
|
||||
options=[NONE_SENTINEL] + sorted([cls.value for cls in SensorStateClass]),
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="state_class",
|
||||
)
|
||||
),
|
||||
vol.Optional(CONF_UNIT_OF_MEASUREMENT): SelectSelector(
|
||||
vol.Required(CONF_UNIT_OF_MEASUREMENT): SelectSelector(
|
||||
SelectSelectorConfig(
|
||||
options=[cls.value for cls in UnitOfTemperature],
|
||||
options=[NONE_SENTINEL] + sorted([cls.value for cls in UnitOfTemperature]),
|
||||
custom_value=True,
|
||||
mode=SelectSelectorMode.DROPDOWN,
|
||||
translation_key="unit_of_measurement",
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _strip_sentinel(options: dict[str, Any]) -> None:
|
||||
"""Convert sentinel to None."""
|
||||
for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT):
|
||||
if options[key] == NONE_SENTINEL:
|
||||
options.pop(key)
|
||||
|
||||
|
||||
async def validate_rest_setup(
|
||||
handler: SchemaCommonFlowHandler, user_input: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
@@ -150,6 +169,7 @@ async def validate_sensor_setup(
|
||||
# Standard behavior is to merge the result with the options.
|
||||
# In this case, we want to add a sub-item so we update the options directly.
|
||||
sensors: list[dict[str, Any]] = handler.options.setdefault(SENSOR_DOMAIN, [])
|
||||
_strip_sentinel(user_input)
|
||||
sensors.append(user_input)
|
||||
return {}
|
||||
|
||||
@@ -181,7 +201,11 @@ async def get_edit_sensor_suggested_values(
|
||||
) -> dict[str, Any]:
|
||||
"""Return suggested values for sensor editing."""
|
||||
idx: int = handler.flow_state["_idx"]
|
||||
return cast(dict[str, Any], handler.options[SENSOR_DOMAIN][idx])
|
||||
suggested_values: dict[str, Any] = dict(handler.options[SENSOR_DOMAIN][idx])
|
||||
for key in (CONF_DEVICE_CLASS, CONF_STATE_CLASS, CONF_UNIT_OF_MEASUREMENT):
|
||||
if not suggested_values.get(key):
|
||||
suggested_values[key] = NONE_SENTINEL
|
||||
return suggested_values
|
||||
|
||||
|
||||
async def validate_sensor_edit(
|
||||
@@ -194,6 +218,7 @@ async def validate_sensor_edit(
|
||||
# In this case, we want to add a sub-item so we update the options directly.
|
||||
idx: int = handler.flow_state["_idx"]
|
||||
handler.options[SENSOR_DOMAIN][idx].update(user_input)
|
||||
_strip_sentinel(handler.options[SENSOR_DOMAIN][idx])
|
||||
return {}
|
||||
|
||||
|
||||
|
||||
@@ -125,5 +125,72 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"selector": {
|
||||
"device_class": {
|
||||
"options": {
|
||||
"none": "No device class",
|
||||
"date": "[%key:component::sensor::entity_component::date::name%]",
|
||||
"duration": "[%key:component::sensor::entity_component::duration::name%]",
|
||||
"apparent_power": "[%key:component::sensor::entity_component::apparent_power::name%]",
|
||||
"aqi": "[%key:component::sensor::entity_component::aqi::name%]",
|
||||
"atmospheric_pressure": "[%key:component::sensor::entity_component::atmospheric_pressure::name%]",
|
||||
"battery": "[%key:component::sensor::entity_component::battery::name%]",
|
||||
"carbon_monoxide": "[%key:component::sensor::entity_component::carbon_monoxide::name%]",
|
||||
"carbon_dioxide": "[%key:component::sensor::entity_component::carbon_dioxide::name%]",
|
||||
"current": "[%key:component::sensor::entity_component::current::name%]",
|
||||
"data_rate": "[%key:component::sensor::entity_component::data_rate::name%]",
|
||||
"data_size": "[%key:component::sensor::entity_component::data_size::name%]",
|
||||
"distance": "[%key:component::sensor::entity_component::distance::name%]",
|
||||
"energy": "[%key:component::sensor::entity_component::energy::name%]",
|
||||
"energy_storage": "[%key:component::sensor::entity_component::energy_storage::name%]",
|
||||
"frequency": "[%key:component::sensor::entity_component::frequency::name%]",
|
||||
"gas": "[%key:component::sensor::entity_component::gas::name%]",
|
||||
"humidity": "[%key:component::sensor::entity_component::humidity::name%]",
|
||||
"illuminance": "[%key:component::sensor::entity_component::illuminance::name%]",
|
||||
"irradiance": "[%key:component::sensor::entity_component::irradiance::name%]",
|
||||
"moisture": "[%key:component::sensor::entity_component::moisture::name%]",
|
||||
"monetary": "[%key:component::sensor::entity_component::monetary::name%]",
|
||||
"nitrogen_dioxide": "[%key:component::sensor::entity_component::nitrogen_dioxide::name%]",
|
||||
"nitrogen_monoxide": "[%key:component::sensor::entity_component::nitrogen_monoxide::name%]",
|
||||
"nitrous_oxide": "[%key:component::sensor::entity_component::nitrous_oxide::name%]",
|
||||
"ozone": "[%key:component::sensor::entity_component::ozone::name%]",
|
||||
"pm1": "[%key:component::sensor::entity_component::pm1::name%]",
|
||||
"pm10": "[%key:component::sensor::entity_component::pm10::name%]",
|
||||
"pm25": "[%key:component::sensor::entity_component::pm25::name%]",
|
||||
"power_factor": "[%key:component::sensor::entity_component::power_factor::name%]",
|
||||
"power": "[%key:component::sensor::entity_component::power::name%]",
|
||||
"precipitation": "[%key:component::sensor::entity_component::precipitation::name%]",
|
||||
"precipitation_intensity": "[%key:component::sensor::entity_component::precipitation_intensity::name%]",
|
||||
"pressure": "[%key:component::sensor::entity_component::pressure::name%]",
|
||||
"reactive_power": "[%key:component::sensor::entity_component::reactive_power::name%]",
|
||||
"signal_strength": "[%key:component::sensor::entity_component::signal_strength::name%]",
|
||||
"sound_pressure": "[%key:component::sensor::entity_component::sound_pressure::name%]",
|
||||
"speed": "[%key:component::sensor::entity_component::speed::name%]",
|
||||
"sulphur_dioxide": "[%key:component::sensor::entity_component::sulphur_dioxide::name%]",
|
||||
"temperature": "[%key:component::sensor::entity_component::temperature::name%]",
|
||||
"timestamp": "[%key:component::sensor::entity_component::timestamp::name%]",
|
||||
"volatile_organic_compounds": "[%key:component::sensor::entity_component::volatile_organic_compounds::name%]",
|
||||
"voltage": "[%key:component::sensor::entity_component::voltage::name%]",
|
||||
"volume": "[%key:component::sensor::entity_component::volume::name%]",
|
||||
"volume_storage": "[%key:component::sensor::entity_component::volume_storage::name%]",
|
||||
"water": "[%key:component::sensor::entity_component::water::name%]",
|
||||
"weight": "[%key:component::sensor::entity_component::weight::name%]",
|
||||
"wind_speed": "[%key:component::sensor::entity_component::wind_speed::name%]"
|
||||
}
|
||||
},
|
||||
"state_class": {
|
||||
"options": {
|
||||
"none": "No state class",
|
||||
"measurement": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::measurement%]",
|
||||
"total": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total%]",
|
||||
"total_increasing": "[%key:component::sensor::entity_component::_::state_attributes::state_class::state::total_increasing%]"
|
||||
}
|
||||
},
|
||||
"unit_of_measurement": {
|
||||
"options": {
|
||||
"none": "No unit of measurement"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -160,6 +160,9 @@
|
||||
"energy_storage": {
|
||||
"name": "Stored energy"
|
||||
},
|
||||
"enum": {
|
||||
"name": "[%key:component::sensor::title%]"
|
||||
},
|
||||
"frequency": {
|
||||
"name": "Frequency"
|
||||
},
|
||||
@@ -235,6 +238,9 @@
|
||||
"temperature": {
|
||||
"name": "Temperature"
|
||||
},
|
||||
"timestamp": {
|
||||
"name": "Timestamp"
|
||||
},
|
||||
"volatile_organic_compounds": {
|
||||
"name": "VOCs"
|
||||
},
|
||||
|
||||
@@ -591,13 +591,20 @@ class SonosSpeaker:
|
||||
self.async_write_entity_states()
|
||||
self.hass.async_create_task(self.async_subscribe())
|
||||
|
||||
async def async_check_activity(self, now: datetime.datetime) -> None:
|
||||
@callback
|
||||
def async_check_activity(self, now: datetime.datetime) -> None:
|
||||
"""Validate availability of the speaker based on recent activity."""
|
||||
if not self.available:
|
||||
return
|
||||
if time.monotonic() - self._last_activity < AVAILABILITY_TIMEOUT:
|
||||
return
|
||||
# Ensure the ping is canceled at shutdown
|
||||
self.hass.async_create_background_task(
|
||||
self._async_check_activity(), f"sonos {self.uid} {self.zone_name} ping"
|
||||
)
|
||||
|
||||
async def _async_check_activity(self) -> None:
|
||||
"""Validate availability of the speaker based on recent activity."""
|
||||
try:
|
||||
await self.hass.async_add_executor_job(self.ping)
|
||||
except SonosUpdateError:
|
||||
|
||||
@@ -15,6 +15,8 @@ from homeassistant.components.sensor import (
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import DEGREE
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.device_registry import DeviceEntryType
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
from homeassistant.helpers.typing import StateType
|
||||
|
||||
@@ -126,6 +128,12 @@ class SunSensor(SensorEntity):
|
||||
self._attr_unique_id = f"{entry_id}-{entity_description.key}"
|
||||
self.sun = sun
|
||||
|
||||
self._attr_device_info = DeviceInfo(
|
||||
name="Sun",
|
||||
identifiers={(DOMAIN, entry_id)},
|
||||
entry_type=DeviceEntryType.SERVICE,
|
||||
)
|
||||
|
||||
@property
|
||||
def native_value(self) -> StateType | datetime:
|
||||
"""Return value of sensor."""
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Persistently store thread datasets."""
|
||||
from __future__ import annotations
|
||||
|
||||
from contextlib import suppress
|
||||
import dataclasses
|
||||
from datetime import datetime
|
||||
from functools import cached_property
|
||||
@@ -35,6 +36,15 @@ class DatasetEntry:
|
||||
created: datetime = dataclasses.field(default_factory=dt_util.utcnow)
|
||||
id: str = dataclasses.field(default_factory=ulid_util.ulid)
|
||||
|
||||
@property
|
||||
def channel(self) -> int | None:
|
||||
"""Return channel as an integer."""
|
||||
if (channel := self.dataset.get(tlv_parser.MeshcopTLVType.CHANNEL)) is None:
|
||||
return None
|
||||
with suppress(ValueError):
|
||||
return int(channel, 16)
|
||||
return None
|
||||
|
||||
@cached_property
|
||||
def dataset(self) -> dict[tlv_parser.MeshcopTLVType, str]:
|
||||
"""Return the dataset in dict format."""
|
||||
|
||||
@@ -144,6 +144,7 @@ async def ws_list_datasets(
|
||||
for dataset in store.datasets.values():
|
||||
result.append(
|
||||
{
|
||||
"channel": dataset.channel,
|
||||
"created": dataset.created,
|
||||
"dataset_id": dataset.id,
|
||||
"extended_pan_id": dataset.extended_pan_id,
|
||||
|
||||
@@ -9,6 +9,7 @@ from homeassistant.components.alarm_control_panel import (
|
||||
CodeFormat,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import STATE_ALARM_ARMING, STATE_ALARM_DISARMING
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.entity import DeviceInfo
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -83,18 +84,24 @@ class VerisureAlarm(
|
||||
|
||||
async def async_alarm_disarm(self, code: str | None = None) -> None:
|
||||
"""Send disarm command."""
|
||||
self._attr_state = STATE_ALARM_DISARMING
|
||||
self.async_write_ha_state()
|
||||
await self._async_set_arm_state(
|
||||
"DISARMED", self.coordinator.verisure.disarm(code)
|
||||
)
|
||||
|
||||
async def async_alarm_arm_home(self, code: str | None = None) -> None:
|
||||
"""Send arm home command."""
|
||||
self._attr_state = STATE_ALARM_ARMING
|
||||
self.async_write_ha_state()
|
||||
await self._async_set_arm_state(
|
||||
"ARMED_HOME", self.coordinator.verisure.arm_home(code)
|
||||
)
|
||||
|
||||
async def async_alarm_arm_away(self, code: str | None = None) -> None:
|
||||
"""Send arm away command."""
|
||||
self._attr_state = STATE_ALARM_ARMING
|
||||
self.async_write_ha_state()
|
||||
await self._async_set_arm_state(
|
||||
"ARMED_AWAY", self.coordinator.verisure.arm_away(code)
|
||||
)
|
||||
|
||||
@@ -12,5 +12,5 @@
|
||||
"dependencies": ["bluetooth_adapters"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/yalexs_ble",
|
||||
"iot_class": "local_push",
|
||||
"requirements": ["yalexs-ble==2.1.13"]
|
||||
"requirements": ["yalexs-ble==2.1.14"]
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
}
|
||||
},
|
||||
"error": {
|
||||
"no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and again.",
|
||||
"no_longer_in_range": "The lock is no longer in Bluetooth range. Move the lock or adapter and try again.",
|
||||
"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%]",
|
||||
|
||||
@@ -18,8 +18,6 @@ from .core.const import (
|
||||
from .core.gateway import ZHAGateway
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from zigpy.application import ControllerApplication
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
@@ -49,21 +47,17 @@ def _get_config_entry(hass: HomeAssistant) -> ConfigEntry:
|
||||
return entries[0]
|
||||
|
||||
|
||||
def _wrap_network_settings(app: ControllerApplication) -> NetworkBackup:
|
||||
"""Wrap the ZHA network settings into a `NetworkBackup`."""
|
||||
def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup:
|
||||
"""Get the network settings for the currently active ZHA network."""
|
||||
zha_gateway: ZHAGateway = _get_gateway(hass)
|
||||
app = zha_gateway.application_controller
|
||||
|
||||
return NetworkBackup(
|
||||
node_info=app.state.node_info,
|
||||
network_info=app.state.network_info,
|
||||
)
|
||||
|
||||
|
||||
def async_get_active_network_settings(hass: HomeAssistant) -> NetworkBackup:
|
||||
"""Get the network settings for the currently active ZHA network."""
|
||||
zha_gateway: ZHAGateway = _get_gateway(hass)
|
||||
|
||||
return _wrap_network_settings(zha_gateway.application_controller)
|
||||
|
||||
|
||||
async def async_get_last_network_settings(
|
||||
hass: HomeAssistant, config_entry: ConfigEntry | None = None
|
||||
) -> NetworkBackup | None:
|
||||
@@ -79,13 +73,12 @@ async def async_get_last_network_settings(
|
||||
|
||||
try:
|
||||
await app._load_db() # pylint: disable=protected-access
|
||||
settings = _wrap_network_settings(app)
|
||||
settings = max(app.backups, key=lambda b: b.backup_time)
|
||||
except ValueError:
|
||||
settings = None
|
||||
finally:
|
||||
await app.shutdown()
|
||||
|
||||
if settings.network_info.channel == 0:
|
||||
return None
|
||||
|
||||
return settings
|
||||
|
||||
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
from typing import Any
|
||||
|
||||
from zigpy.zcl.clusters.security import IasZone
|
||||
|
||||
from homeassistant.components.binary_sensor import (
|
||||
BinarySensorDeviceClass,
|
||||
BinarySensorEntity,
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory, Platform
|
||||
from homeassistant.const import STATE_ON, EntityCategory, Platform
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.helpers.dispatcher import async_dispatcher_connect
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
@@ -164,6 +167,36 @@ class IASZone(BinarySensor):
|
||||
"""Parse the raw attribute into a bool state."""
|
||||
return BinarySensor.parse(value & 3) # use only bit 0 and 1 for alarm state
|
||||
|
||||
# temporary code to migrate old IasZone sensors to update attribute cache state once
|
||||
# remove in 2024.4.0
|
||||
@property
|
||||
def extra_state_attributes(self) -> dict[str, Any]:
|
||||
"""Return state attributes."""
|
||||
return {"migrated_to_cache": True} # writing new state means we're migrated
|
||||
|
||||
# temporary migration code
|
||||
@callback
|
||||
def async_restore_last_state(self, last_state):
|
||||
"""Restore previous state."""
|
||||
# trigger migration if extra state attribute is not present
|
||||
if "migrated_to_cache" not in last_state.attributes:
|
||||
self.migrate_to_zigpy_cache(last_state)
|
||||
|
||||
# temporary migration code
|
||||
@callback
|
||||
def migrate_to_zigpy_cache(self, last_state):
|
||||
"""Save old IasZone sensor state to attribute cache."""
|
||||
# previous HA versions did not update the attribute cache for IasZone sensors, so do it once here
|
||||
# a HA state write is triggered shortly afterwards and writes the "migrated_to_cache" extra state attribute
|
||||
if last_state.state == STATE_ON:
|
||||
migrated_state = IasZone.ZoneStatus.Alarm_1
|
||||
else:
|
||||
migrated_state = IasZone.ZoneStatus(0)
|
||||
|
||||
self._channel.cluster.update_attribute(
|
||||
IasZone.attributes_by_name[self.SENSOR_ATTR].id, migrated_state
|
||||
)
|
||||
|
||||
|
||||
@MULTI_MATCH(
|
||||
channel_names="tuya_manufacturer",
|
||||
|
||||
@@ -58,15 +58,19 @@ class AttrReportConfig(TypedDict, total=True):
|
||||
|
||||
def parse_and_log_command(channel, tsn, command_id, args):
|
||||
"""Parse and log a zigbee cluster command."""
|
||||
cmd = channel.cluster.server_commands.get(command_id, [command_id])[0]
|
||||
try:
|
||||
name = channel.cluster.server_commands[command_id].name
|
||||
except KeyError:
|
||||
name = f"0x{command_id:02X}"
|
||||
|
||||
channel.debug(
|
||||
"received '%s' command with %s args on cluster_id '%s' tsn '%s'",
|
||||
cmd,
|
||||
name,
|
||||
args,
|
||||
channel.cluster.cluster_id,
|
||||
tsn,
|
||||
)
|
||||
return cmd
|
||||
return name
|
||||
|
||||
|
||||
def decorate_command(channel, command):
|
||||
|
||||
@@ -137,6 +137,8 @@ CONF_GROUP_MEMBERS_ASSUME_STATE = "group_members_assume_state"
|
||||
CONF_ENABLE_IDENTIFY_ON_JOIN = "enable_identify_on_join"
|
||||
CONF_ENABLE_QUIRKS = "enable_quirks"
|
||||
CONF_FLOWCONTROL = "flow_control"
|
||||
CONF_NWK = "network"
|
||||
CONF_NWK_CHANNEL = "channel"
|
||||
CONF_RADIO_TYPE = "radio_type"
|
||||
CONF_USB_PATH = "usb_path"
|
||||
CONF_USE_THREAD = "use_thread"
|
||||
|
||||
@@ -41,6 +41,8 @@ from .const import (
|
||||
ATTR_TYPE,
|
||||
CONF_DATABASE,
|
||||
CONF_DEVICE_PATH,
|
||||
CONF_NWK,
|
||||
CONF_NWK_CHANNEL,
|
||||
CONF_RADIO_TYPE,
|
||||
CONF_USE_THREAD,
|
||||
CONF_ZIGPY,
|
||||
@@ -172,6 +174,20 @@ class ZHAGateway:
|
||||
):
|
||||
app_config[CONF_USE_THREAD] = False
|
||||
|
||||
# Local import to avoid circular dependencies
|
||||
# pylint: disable-next=import-outside-toplevel
|
||||
from homeassistant.components.homeassistant_hardware.silabs_multiprotocol_addon import (
|
||||
is_multiprotocol_url,
|
||||
)
|
||||
|
||||
# Until we have a way to coordinate channels with the Thread half of multi-PAN,
|
||||
# stick to the old zigpy default of channel 15 instead of dynamically scanning
|
||||
if (
|
||||
is_multiprotocol_url(app_config[CONF_DEVICE][CONF_DEVICE_PATH])
|
||||
and app_config.get(CONF_NWK, {}).get(CONF_NWK_CHANNEL) is None
|
||||
):
|
||||
app_config.setdefault(CONF_NWK, {})[CONF_NWK_CHANNEL] = 15
|
||||
|
||||
return app_controller_cls, app_controller_cls.SCHEMA(app_config)
|
||||
|
||||
async def async_initialize(self) -> None:
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
from zigpy.config import CONF_NWK_EXTENDED_PAN_ID
|
||||
from zigpy.profiles import PROFILES
|
||||
from zigpy.types import Channels
|
||||
from zigpy.zcl import Cluster
|
||||
|
||||
from homeassistant.components.diagnostics.util import async_redact_data
|
||||
@@ -67,11 +68,19 @@ async def async_get_config_entry_diagnostics(
|
||||
"""Return diagnostics for a config entry."""
|
||||
config: dict = hass.data[DATA_ZHA].get(DATA_ZHA_CONFIG, {})
|
||||
gateway: ZHAGateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
|
||||
|
||||
energy_scan = await gateway.application_controller.energy_scan(
|
||||
channels=Channels.ALL_CHANNELS, duration_exp=4, count=1
|
||||
)
|
||||
|
||||
return async_redact_data(
|
||||
{
|
||||
"config": config,
|
||||
"config_entry": config_entry.as_dict(),
|
||||
"application_state": shallow_asdict(gateway.application_controller.state),
|
||||
"energy_scan": {
|
||||
channel: 100 * energy / 255 for channel, energy in energy_scan.items()
|
||||
},
|
||||
"versions": {
|
||||
"bellows": version("bellows"),
|
||||
"zigpy": version("zigpy"),
|
||||
|
||||
@@ -20,15 +20,15 @@
|
||||
"zigpy_znp"
|
||||
],
|
||||
"requirements": [
|
||||
"bellows==0.34.10",
|
||||
"bellows==0.35.0",
|
||||
"pyserial==3.5",
|
||||
"pyserial-asyncio==0.6",
|
||||
"zha-quirks==0.0.95",
|
||||
"zigpy-deconz==0.19.2",
|
||||
"zigpy==0.53.2",
|
||||
"zigpy-xbee==0.16.2",
|
||||
"zigpy-deconz==0.20.0",
|
||||
"zigpy==0.54.0",
|
||||
"zigpy-xbee==0.17.0",
|
||||
"zigpy-zigate==0.10.3",
|
||||
"zigpy-znp==0.9.3"
|
||||
"zigpy-znp==0.10.0"
|
||||
],
|
||||
"usb": [
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["zwave_js_server"],
|
||||
"requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.1"],
|
||||
"requirements": ["pyserial==3.5", "zwave-js-server-python==0.47.3"],
|
||||
"usb": [
|
||||
{
|
||||
"vid": "0658",
|
||||
|
||||
@@ -8,7 +8,7 @@ from .backports.enum import StrEnum
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2023
|
||||
MINOR_VERSION: Final = 4
|
||||
PATCH_VERSION: Final = "0b0"
|
||||
PATCH_VERSION: Final = "0b3"
|
||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0)
|
||||
|
||||
@@ -1950,7 +1950,11 @@ class Config:
|
||||
)
|
||||
|
||||
def is_allowed_path(self, path: str) -> bool:
|
||||
"""Check if the path is valid for access from outside."""
|
||||
"""Check if the path is valid for access from outside.
|
||||
|
||||
This function does blocking I/O and should not be called from the event loop.
|
||||
Use hass.async_add_executor_job to schedule it on the executor.
|
||||
"""
|
||||
assert path is not None
|
||||
|
||||
thepath = pathlib.Path(path)
|
||||
|
||||
@@ -2285,9 +2285,6 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
|
||||
self.globals["area_devices"] = hassfunction(area_devices)
|
||||
self.filters["area_devices"] = pass_context(self.globals["area_devices"])
|
||||
|
||||
self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity)
|
||||
self.tests["is_hidden_entity"] = pass_context(self.globals["is_hidden_entity"])
|
||||
|
||||
self.globals["integration_entities"] = hassfunction(integration_entities)
|
||||
self.filters["integration_entities"] = pass_context(
|
||||
self.globals["integration_entities"]
|
||||
@@ -2308,6 +2305,7 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
|
||||
"closest",
|
||||
"distance",
|
||||
"expand",
|
||||
"is_hidden_entity",
|
||||
"is_state",
|
||||
"is_state_attr",
|
||||
"state_attr",
|
||||
@@ -2331,7 +2329,12 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
|
||||
"area_name",
|
||||
"has_value",
|
||||
]
|
||||
hass_tests = ["has_value"]
|
||||
hass_tests = [
|
||||
"has_value",
|
||||
"is_hidden_entity",
|
||||
"is_state",
|
||||
"is_state_attr",
|
||||
]
|
||||
for glob in hass_globals:
|
||||
self.globals[glob] = unsupported(glob)
|
||||
for filt in hass_filters:
|
||||
@@ -2345,6 +2348,10 @@ class TemplateEnvironment(ImmutableSandboxedEnvironment):
|
||||
self.globals["closest"] = hassfunction(closest)
|
||||
self.filters["closest"] = pass_context(hassfunction(closest_filter))
|
||||
self.globals["distance"] = hassfunction(distance)
|
||||
self.globals["is_hidden_entity"] = hassfunction(is_hidden_entity)
|
||||
self.tests["is_hidden_entity"] = pass_eval_context(
|
||||
self.globals["is_hidden_entity"]
|
||||
)
|
||||
self.globals["is_state"] = hassfunction(is_state)
|
||||
self.tests["is_state"] = pass_eval_context(self.globals["is_state"])
|
||||
self.globals["is_state_attr"] = hassfunction(is_state_attr)
|
||||
|
||||
@@ -25,7 +25,7 @@ ha-av==10.0.0
|
||||
hass-nabucasa==0.63.1
|
||||
hassil==1.0.6
|
||||
home-assistant-bluetooth==1.9.3
|
||||
home-assistant-frontend==20230329.0
|
||||
home-assistant-frontend==20230331.0
|
||||
home-assistant-intents==2023.3.29
|
||||
httpx==0.23.3
|
||||
ifaddr==0.1.7
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2023.4.0b0"
|
||||
version = "2023.4.0b3"
|
||||
license = {text = "Apache-2.0"}
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
readme = "README.rst"
|
||||
|
||||
@@ -422,7 +422,7 @@ beautifulsoup4==4.11.1
|
||||
# beewi_smartclim==0.0.10
|
||||
|
||||
# homeassistant.components.zha
|
||||
bellows==0.34.10
|
||||
bellows==0.35.0
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer_connected==0.13.0
|
||||
@@ -907,7 +907,7 @@ hole==0.8.0
|
||||
holidays==0.21.13
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20230329.0
|
||||
home-assistant-frontend==20230331.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2023.3.29
|
||||
@@ -2234,7 +2234,7 @@ regenmaschine==2022.11.0
|
||||
renault-api==0.1.12
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.5.7
|
||||
reolink-aio==0.5.9
|
||||
|
||||
# homeassistant.components.python_script
|
||||
restrictedpython==6.0
|
||||
@@ -2668,7 +2668,7 @@ yalesmartalarmclient==0.3.9
|
||||
|
||||
# homeassistant.components.august
|
||||
# homeassistant.components.yalexs_ble
|
||||
yalexs-ble==2.1.13
|
||||
yalexs-ble==2.1.14
|
||||
|
||||
# homeassistant.components.august
|
||||
yalexs==1.2.7
|
||||
@@ -2710,25 +2710,25 @@ zhong_hong_hvac==1.0.9
|
||||
ziggo-mediabox-xl==1.1.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-deconz==0.19.2
|
||||
zigpy-deconz==0.20.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-xbee==0.16.2
|
||||
zigpy-xbee==0.17.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-zigate==0.10.3
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-znp==0.9.3
|
||||
zigpy-znp==0.10.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy==0.53.2
|
||||
zigpy==0.54.0
|
||||
|
||||
# homeassistant.components.zoneminder
|
||||
zm-py==0.5.2
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.47.1
|
||||
zwave-js-server-python==0.47.3
|
||||
|
||||
# homeassistant.components.zwave_me
|
||||
zwave_me_ws==0.3.6
|
||||
|
||||
@@ -355,7 +355,7 @@ base36==0.1.1
|
||||
beautifulsoup4==4.11.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
bellows==0.34.10
|
||||
bellows==0.35.0
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer_connected==0.13.0
|
||||
@@ -693,7 +693,7 @@ hole==0.8.0
|
||||
holidays==0.21.13
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20230329.0
|
||||
home-assistant-frontend==20230331.0
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2023.3.29
|
||||
@@ -1597,7 +1597,7 @@ regenmaschine==2022.11.0
|
||||
renault-api==0.1.12
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.5.7
|
||||
reolink-aio==0.5.9
|
||||
|
||||
# homeassistant.components.python_script
|
||||
restrictedpython==6.0
|
||||
@@ -1911,7 +1911,7 @@ yalesmartalarmclient==0.3.9
|
||||
|
||||
# homeassistant.components.august
|
||||
# homeassistant.components.yalexs_ble
|
||||
yalexs-ble==2.1.13
|
||||
yalexs-ble==2.1.14
|
||||
|
||||
# homeassistant.components.august
|
||||
yalexs==1.2.7
|
||||
@@ -1938,22 +1938,22 @@ zeversolar==0.3.1
|
||||
zha-quirks==0.0.95
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-deconz==0.19.2
|
||||
zigpy-deconz==0.20.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-xbee==0.16.2
|
||||
zigpy-xbee==0.17.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-zigate==0.10.3
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy-znp==0.9.3
|
||||
zigpy-znp==0.10.0
|
||||
|
||||
# homeassistant.components.zha
|
||||
zigpy==0.53.2
|
||||
zigpy==0.54.0
|
||||
|
||||
# homeassistant.components.zwave_js
|
||||
zwave-js-server-python==0.47.1
|
||||
zwave-js-server-python==0.47.3
|
||||
|
||||
# homeassistant.components.zwave_me
|
||||
zwave_me_ws==0.3.6
|
||||
|
||||
@@ -3,8 +3,11 @@ from unittest.mock import patch
|
||||
|
||||
from homeassistant.components.ezviz.const import (
|
||||
ATTR_SERIAL,
|
||||
ATTR_TYPE_CAMERA,
|
||||
ATTR_TYPE_CLOUD,
|
||||
CONF_FFMPEG_ARGUMENTS,
|
||||
CONF_RFSESSION_ID,
|
||||
CONF_SESSION_ID,
|
||||
DEFAULT_FFMPEG_ARGUMENTS,
|
||||
DEFAULT_TIMEOUT,
|
||||
DOMAIN,
|
||||
@@ -22,8 +25,8 @@ from homeassistant.core import HomeAssistant
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
ENTRY_CONFIG = {
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_SESSION_ID: "test-username",
|
||||
CONF_RFSESSION_ID: "test-password",
|
||||
CONF_URL: "apiieu.ezvizlife.com",
|
||||
CONF_TYPE: ATTR_TYPE_CLOUD,
|
||||
}
|
||||
@@ -46,6 +49,18 @@ USER_INPUT = {
|
||||
CONF_TYPE: ATTR_TYPE_CLOUD,
|
||||
}
|
||||
|
||||
USER_INPUT_CAMERA_VALIDATE = {
|
||||
ATTR_SERIAL: "C666666",
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_USERNAME: "test-username",
|
||||
}
|
||||
|
||||
USER_INPUT_CAMERA = {
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_TYPE: ATTR_TYPE_CAMERA,
|
||||
}
|
||||
|
||||
DISCOVERY_INFO = {
|
||||
ATTR_SERIAL: "C666666",
|
||||
CONF_USERNAME: None,
|
||||
@@ -59,6 +74,13 @@ TEST = {
|
||||
CONF_IP_ADDRESS: "127.0.0.1",
|
||||
}
|
||||
|
||||
API_LOGIN_RETURN_VALIDATE = {
|
||||
CONF_SESSION_ID: "fake_token",
|
||||
CONF_RFSESSION_ID: "fake_rf_token",
|
||||
CONF_URL: "apiieu.ezvizlife.com",
|
||||
CONF_TYPE: ATTR_TYPE_CLOUD,
|
||||
}
|
||||
|
||||
|
||||
def _patch_async_setup_entry(return_value=True):
|
||||
return patch(
|
||||
|
||||
@@ -5,6 +5,12 @@ from pyezviz import EzvizClient
|
||||
from pyezviz.test_cam_rtsp import TestRTSPAuth
|
||||
import pytest
|
||||
|
||||
ezviz_login_token_return = {
|
||||
"session_id": "fake_token",
|
||||
"rf_session_id": "fake_rf_token",
|
||||
"api_url": "apiieu.ezvizlife.com",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def mock_ffmpeg(hass):
|
||||
@@ -42,7 +48,7 @@ def ezviz_config_flow(hass):
|
||||
"1",
|
||||
)
|
||||
|
||||
instance.login = MagicMock(return_value=True)
|
||||
instance.login = MagicMock(return_value=ezviz_login_token_return)
|
||||
instance.get_detection_sensibility = MagicMock(return_value=True)
|
||||
|
||||
yield mock_ezviz
|
||||
|
||||
@@ -3,6 +3,7 @@ from unittest.mock import patch
|
||||
|
||||
from pyezviz.exceptions import (
|
||||
AuthTestResultFailed,
|
||||
EzvizAuthVerificationCode,
|
||||
HTTPError,
|
||||
InvalidHost,
|
||||
InvalidURL,
|
||||
@@ -12,13 +13,16 @@ from pyezviz.exceptions import (
|
||||
from homeassistant.components.ezviz.const import (
|
||||
ATTR_SERIAL,
|
||||
ATTR_TYPE_CAMERA,
|
||||
ATTR_TYPE_CLOUD,
|
||||
CONF_FFMPEG_ARGUMENTS,
|
||||
DEFAULT_FFMPEG_ARGUMENTS,
|
||||
DEFAULT_TIMEOUT,
|
||||
DOMAIN,
|
||||
)
|
||||
from homeassistant.config_entries import SOURCE_INTEGRATION_DISCOVERY, SOURCE_USER
|
||||
from homeassistant.config_entries import (
|
||||
SOURCE_INTEGRATION_DISCOVERY,
|
||||
SOURCE_REAUTH,
|
||||
SOURCE_USER,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
CONF_CUSTOMIZE,
|
||||
CONF_IP_ADDRESS,
|
||||
@@ -32,8 +36,8 @@ from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
|
||||
from . import (
|
||||
API_LOGIN_RETURN_VALIDATE,
|
||||
DISCOVERY_INFO,
|
||||
USER_INPUT,
|
||||
USER_INPUT_VALIDATE,
|
||||
_patch_async_setup_entry,
|
||||
init_integration,
|
||||
@@ -59,7 +63,7 @@ async def test_user_form(hass: HomeAssistant, ezviz_config_flow) -> None:
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "test-username"
|
||||
assert result["data"] == {**USER_INPUT}
|
||||
assert result["data"] == {**API_LOGIN_RETURN_VALIDATE}
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
@@ -78,7 +82,11 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None:
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_USERNAME: "test-user", CONF_PASSWORD: "test-pass", CONF_URL: "customize"},
|
||||
{
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_PASSWORD: "test-password",
|
||||
CONF_URL: CONF_CUSTOMIZE,
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
@@ -90,21 +98,58 @@ async def test_user_custom_url(hass: HomeAssistant, ezviz_config_flow) -> None:
|
||||
result["flow_id"],
|
||||
{CONF_URL: "test-user"},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_PASSWORD: "test-pass",
|
||||
CONF_TYPE: ATTR_TYPE_CLOUD,
|
||||
CONF_URL: "test-user",
|
||||
CONF_USERNAME: "test-user",
|
||||
}
|
||||
assert result["data"] == API_LOGIN_RETURN_VALIDATE
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_step_discovery_abort_if_cloud_account_missing(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
async def test_async_step_reauth(hass, ezviz_config_flow):
|
||||
"""Test the reauth step."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {}
|
||||
|
||||
with _patch_async_setup_entry() as mock_setup_entry:
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
USER_INPUT_VALIDATE,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "test-username"
|
||||
assert result["data"] == {**API_LOGIN_RETURN_VALIDATE}
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {}
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "reauth_successful"
|
||||
|
||||
|
||||
async def test_step_discovery_abort_if_cloud_account_missing(hass):
|
||||
"""Test discovery and confirm step, abort if cloud account was removed."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -127,11 +172,21 @@ async def test_step_discovery_abort_if_cloud_account_missing(
|
||||
assert result["reason"] == "ezviz_cloud_account_missing"
|
||||
|
||||
|
||||
async def test_step_reauth_abort_if_cloud_account_missing(hass):
|
||||
"""Test reauth and confirm step, abort if cloud account was removed."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE
|
||||
)
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "ezviz_cloud_account_missing"
|
||||
|
||||
|
||||
async def test_async_step_integration_discovery(
|
||||
hass: HomeAssistant, ezviz_config_flow, ezviz_test_rtsp_config_flow
|
||||
) -> None:
|
||||
hass, ezviz_config_flow, ezviz_test_rtsp_config_flow
|
||||
):
|
||||
"""Test discovery and confirm step."""
|
||||
with patch("homeassistant.components.ezviz.PLATFORMS", []):
|
||||
with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []):
|
||||
await init_integration(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -189,11 +244,14 @@ async def test_options_flow(hass: HomeAssistant) -> None:
|
||||
|
||||
async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> None:
|
||||
"""Test we handle exception on user form."""
|
||||
ezviz_config_flow.side_effect = PyEzvizError
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {}
|
||||
|
||||
ezviz_config_flow.side_effect = PyEzvizError
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
@@ -215,6 +273,17 @@ async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> No
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "invalid_host"}
|
||||
|
||||
ezviz_config_flow.side_effect = EzvizAuthVerificationCode
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
USER_INPUT_VALIDATE,
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "mfa_required"}
|
||||
|
||||
ezviz_config_flow.side_effect = HTTPError
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
@@ -224,7 +293,7 @@ async def test_user_form_exception(hass: HomeAssistant, ezviz_config_flow) -> No
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
ezviz_config_flow.side_effect = Exception
|
||||
|
||||
@@ -242,7 +311,7 @@ async def test_discover_exception_step1(
|
||||
ezviz_config_flow,
|
||||
) -> None:
|
||||
"""Test we handle unexpected exception on discovery."""
|
||||
with patch("homeassistant.components.ezviz.PLATFORMS", []):
|
||||
with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []):
|
||||
await init_integration(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -295,7 +364,21 @@ async def test_discover_exception_step1(
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "confirm"
|
||||
assert result["errors"] == {"base": "invalid_host"}
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
ezviz_config_flow.side_effect = EzvizAuthVerificationCode
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_USERNAME: "test-user",
|
||||
CONF_PASSWORD: "test-pass",
|
||||
},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "confirm"
|
||||
assert result["errors"] == {"base": "mfa_required"}
|
||||
|
||||
ezviz_config_flow.side_effect = Exception
|
||||
|
||||
@@ -317,7 +400,7 @@ async def test_discover_exception_step3(
|
||||
ezviz_test_rtsp_config_flow,
|
||||
) -> None:
|
||||
"""Test we handle unexpected exception on discovery."""
|
||||
with patch("homeassistant.components.ezviz.PLATFORMS", []):
|
||||
with patch("homeassistant.components.ezviz.PLATFORMS_BY_TYPE", []):
|
||||
await init_integration(hass)
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -423,7 +506,18 @@ async def test_user_custom_url_exception(
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user_custom_url"
|
||||
assert result["errors"] == {"base": "cannot_connect"}
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
ezviz_config_flow.side_effect = EzvizAuthVerificationCode
|
||||
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{CONF_URL: "test-user"},
|
||||
)
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user_custom_url"
|
||||
assert result["errors"] == {"base": "mfa_required"}
|
||||
|
||||
ezviz_config_flow.side_effect = Exception
|
||||
|
||||
@@ -434,3 +528,103 @@ async def test_user_custom_url_exception(
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "unknown"
|
||||
|
||||
|
||||
async def test_async_step_reauth_exception(hass, ezviz_config_flow):
|
||||
"""Test the reauth step exceptions."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_USER}
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "user"
|
||||
assert result["errors"] == {}
|
||||
|
||||
with _patch_async_setup_entry() as mock_setup_entry:
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
USER_INPUT_VALIDATE,
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["title"] == "test-username"
|
||||
assert result["data"] == {**API_LOGIN_RETURN_VALIDATE}
|
||||
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
DOMAIN, context={"source": SOURCE_REAUTH}, data=USER_INPUT_VALIDATE
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {}
|
||||
|
||||
ezviz_config_flow.side_effect = InvalidURL()
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": "invalid_host"}
|
||||
|
||||
ezviz_config_flow.side_effect = InvalidHost()
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": "invalid_host"}
|
||||
|
||||
ezviz_config_flow.side_effect = EzvizAuthVerificationCode()
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": "mfa_required"}
|
||||
|
||||
ezviz_config_flow.side_effect = PyEzvizError()
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert result["errors"] == {"base": "invalid_auth"}
|
||||
|
||||
ezviz_config_flow.side_effect = Exception()
|
||||
result = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
CONF_USERNAME: "test-username",
|
||||
CONF_PASSWORD: "test-password",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == FlowResultType.ABORT
|
||||
assert result["reason"] == "unknown"
|
||||
|
||||
@@ -26,6 +26,7 @@ from homeassistant.const import (
|
||||
STATE_ALARM_TRIGGERED,
|
||||
)
|
||||
from homeassistant.core import CoreState, HomeAssistant, State
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.setup import async_setup_component
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
@@ -224,12 +225,16 @@ async def test_with_invalid_code(hass: HomeAssistant, service, expected_state) -
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED
|
||||
|
||||
await hass.services.async_call(
|
||||
alarm_control_panel.DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: CODE + "2"},
|
||||
blocking=True,
|
||||
)
|
||||
with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"):
|
||||
await hass.services.async_call(
|
||||
alarm_control_panel.DOMAIN,
|
||||
service,
|
||||
{
|
||||
ATTR_ENTITY_ID: "alarm_control_panel.test",
|
||||
ATTR_CODE: f"{CODE}2",
|
||||
},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED
|
||||
|
||||
@@ -1082,7 +1087,8 @@ async def test_disarm_during_trigger_with_invalid_code(hass: HomeAssistant) -> N
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_ALARM_PENDING
|
||||
|
||||
await common.async_alarm_disarm(hass, entity_id=entity_id)
|
||||
with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"):
|
||||
await common.async_alarm_disarm(hass, entity_id=entity_id)
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_ALARM_PENDING
|
||||
|
||||
@@ -1125,7 +1131,8 @@ async def test_disarm_with_template_code(hass: HomeAssistant) -> None:
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == STATE_ALARM_ARMED_HOME
|
||||
|
||||
await common.async_alarm_disarm(hass, "def")
|
||||
with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"):
|
||||
await common.async_alarm_disarm(hass, "def")
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == STATE_ALARM_ARMED_HOME
|
||||
|
||||
@@ -24,6 +24,7 @@ from homeassistant.const import (
|
||||
STATE_ALARM_TRIGGERED,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.exceptions import HomeAssistantError
|
||||
from homeassistant.setup import async_setup_component
|
||||
import homeassistant.util.dt as dt_util
|
||||
|
||||
@@ -280,12 +281,13 @@ async def test_with_invalid_code(
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED
|
||||
|
||||
await hass.services.async_call(
|
||||
alarm_control_panel.DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: f"{CODE}2"},
|
||||
blocking=True,
|
||||
)
|
||||
with pytest.raises(HomeAssistantError, match=r"^Invalid alarm code provided$"):
|
||||
await hass.services.async_call(
|
||||
alarm_control_panel.DOMAIN,
|
||||
service,
|
||||
{ATTR_ENTITY_ID: "alarm_control_panel.test", ATTR_CODE: f"{CODE}2"},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_ALARM_DISARMED
|
||||
|
||||
@@ -881,7 +883,8 @@ async def test_disarm_during_trigger_with_invalid_code(
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_ALARM_PENDING
|
||||
|
||||
await common.async_alarm_disarm(hass, entity_id=entity_id)
|
||||
with pytest.raises(HomeAssistantError, match=r"Invalid alarm code provided$"):
|
||||
await common.async_alarm_disarm(hass, entity_id=entity_id)
|
||||
|
||||
assert hass.states.get(entity_id).state == STATE_ALARM_PENDING
|
||||
|
||||
@@ -1307,7 +1310,8 @@ async def test_disarm_with_template_code(
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == STATE_ALARM_ARMED_HOME
|
||||
|
||||
await common.async_alarm_disarm(hass, "def")
|
||||
with pytest.raises(HomeAssistantError, match=r"Invalid alarm code provided$"):
|
||||
await common.async_alarm_disarm(hass, "def")
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == STATE_ALARM_ARMED_HOME
|
||||
|
||||
@@ -671,6 +671,19 @@ async def test_migrate_events_context_ids(
|
||||
context_parent_id=None,
|
||||
context_parent_id_bin=None,
|
||||
),
|
||||
Events(
|
||||
event_type="garbage_context_id_event",
|
||||
event_data=None,
|
||||
origin_idx=0,
|
||||
time_fired=None,
|
||||
time_fired_ts=1677721632.552529,
|
||||
context_id="adapt_lgt:b'5Cf*':interval:b'0R'",
|
||||
context_id_bin=None,
|
||||
context_user_id=None,
|
||||
context_user_id_bin=None,
|
||||
context_parent_id=None,
|
||||
context_parent_id_bin=None,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -695,12 +708,13 @@ async def test_migrate_events_context_ids(
|
||||
"empty_context_id_event",
|
||||
"ulid_context_id_event",
|
||||
"invalid_context_id_event",
|
||||
"garbage_context_id_event",
|
||||
]
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
assert len(events) == 4
|
||||
assert len(events) == 5
|
||||
return {event.event_type: _object_as_dict(event) for event in events}
|
||||
|
||||
events_by_type = await instance.async_add_executor_job(_fetch_migrated_events)
|
||||
@@ -746,6 +760,14 @@ async def test_migrate_events_context_ids(
|
||||
assert invalid_context_id_event["context_user_id_bin"] is None
|
||||
assert invalid_context_id_event["context_parent_id_bin"] is None
|
||||
|
||||
garbage_context_id_event = events_by_type["garbage_context_id_event"]
|
||||
assert garbage_context_id_event["context_id"] is None
|
||||
assert garbage_context_id_event["context_user_id"] is None
|
||||
assert garbage_context_id_event["context_parent_id"] is None
|
||||
assert garbage_context_id_event["context_id_bin"] == b"\x00" * 16
|
||||
assert garbage_context_id_event["context_user_id_bin"] is None
|
||||
assert garbage_context_id_event["context_parent_id_bin"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_migrate_context_ids", [True])
|
||||
async def test_migrate_states_context_ids(
|
||||
@@ -803,6 +825,16 @@ async def test_migrate_states_context_ids(
|
||||
context_parent_id=None,
|
||||
context_parent_id_bin=None,
|
||||
),
|
||||
States(
|
||||
entity_id="state.garbage_context_id",
|
||||
last_updated_ts=1677721632.552529,
|
||||
context_id="adapt_lgt:b'5Cf*':interval:b'0R'",
|
||||
context_id_bin=None,
|
||||
context_user_id=None,
|
||||
context_user_id_bin=None,
|
||||
context_parent_id=None,
|
||||
context_parent_id_bin=None,
|
||||
),
|
||||
)
|
||||
)
|
||||
|
||||
@@ -827,12 +859,13 @@ async def test_migrate_states_context_ids(
|
||||
"state.empty_context_id",
|
||||
"state.ulid_context_id",
|
||||
"state.invalid_context_id",
|
||||
"state.garbage_context_id",
|
||||
]
|
||||
)
|
||||
)
|
||||
.all()
|
||||
)
|
||||
assert len(events) == 4
|
||||
assert len(events) == 5
|
||||
return {state.entity_id: _object_as_dict(state) for state in events}
|
||||
|
||||
states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states)
|
||||
@@ -877,6 +910,14 @@ async def test_migrate_states_context_ids(
|
||||
assert invalid_context_id["context_user_id_bin"] is None
|
||||
assert invalid_context_id["context_parent_id_bin"] is None
|
||||
|
||||
garbage_context_id = states_by_entity_id["state.garbage_context_id"]
|
||||
assert garbage_context_id["context_id"] is None
|
||||
assert garbage_context_id["context_user_id"] is None
|
||||
assert garbage_context_id["context_parent_id"] is None
|
||||
assert garbage_context_id["context_id_bin"] == b"\x00" * 16
|
||||
assert garbage_context_id["context_user_id_bin"] is None
|
||||
assert garbage_context_id["context_parent_id_bin"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_migrate_event_type_ids", [True])
|
||||
async def test_migrate_event_type_ids(
|
||||
@@ -957,7 +998,7 @@ async def test_migrate_entity_ids(
|
||||
instance = await async_setup_recorder_instance(hass)
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
def _insert_events():
|
||||
def _insert_states():
|
||||
with session_scope(hass=hass) as session:
|
||||
session.add_all(
|
||||
(
|
||||
@@ -979,7 +1020,7 @@ async def test_migrate_entity_ids(
|
||||
)
|
||||
)
|
||||
|
||||
await instance.async_add_executor_job(_insert_events)
|
||||
await instance.async_add_executor_job(_insert_states)
|
||||
|
||||
await async_wait_recording_done(hass)
|
||||
# This is a threadsafe way to add a task to the recorder
|
||||
@@ -1065,3 +1106,149 @@ async def test_post_migrate_entity_ids(
|
||||
assert states_by_state["one_1"] is None
|
||||
assert states_by_state["two_2"] is None
|
||||
assert states_by_state["two_1"] is None
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_migrate_entity_ids", [True])
|
||||
async def test_migrate_null_entity_ids(
|
||||
async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""Test we can migrate entity_ids to the StatesMeta table."""
|
||||
instance = await async_setup_recorder_instance(hass)
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
def _insert_states():
|
||||
with session_scope(hass=hass) as session:
|
||||
session.add(
|
||||
States(
|
||||
entity_id="sensor.one",
|
||||
state="one_1",
|
||||
last_updated_ts=1.452529,
|
||||
),
|
||||
)
|
||||
session.add_all(
|
||||
States(
|
||||
entity_id=None,
|
||||
state="empty",
|
||||
last_updated_ts=time + 1.452529,
|
||||
)
|
||||
for time in range(1000)
|
||||
)
|
||||
session.add(
|
||||
States(
|
||||
entity_id="sensor.one",
|
||||
state="one_1",
|
||||
last_updated_ts=2.452529,
|
||||
),
|
||||
)
|
||||
|
||||
await instance.async_add_executor_job(_insert_states)
|
||||
|
||||
await async_wait_recording_done(hass)
|
||||
# This is a threadsafe way to add a task to the recorder
|
||||
instance.queue_task(EntityIDMigrationTask())
|
||||
await async_recorder_block_till_done(hass)
|
||||
await async_recorder_block_till_done(hass)
|
||||
|
||||
def _fetch_migrated_states():
|
||||
with session_scope(hass=hass) as session:
|
||||
states = (
|
||||
session.query(
|
||||
States.state,
|
||||
States.metadata_id,
|
||||
States.last_updated_ts,
|
||||
StatesMeta.entity_id,
|
||||
)
|
||||
.outerjoin(StatesMeta, States.metadata_id == StatesMeta.metadata_id)
|
||||
.all()
|
||||
)
|
||||
assert len(states) == 1002
|
||||
result = {}
|
||||
for state in states:
|
||||
result.setdefault(state.entity_id, []).append(
|
||||
{
|
||||
"state_id": state.entity_id,
|
||||
"last_updated_ts": state.last_updated_ts,
|
||||
"state": state.state,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
states_by_entity_id = await instance.async_add_executor_job(_fetch_migrated_states)
|
||||
assert len(states_by_entity_id[migration._EMPTY_ENTITY_ID]) == 1000
|
||||
assert len(states_by_entity_id["sensor.one"]) == 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("enable_migrate_event_type_ids", [True])
|
||||
async def test_migrate_null_event_type_ids(
|
||||
async_setup_recorder_instance: RecorderInstanceGenerator, hass: HomeAssistant
|
||||
) -> None:
|
||||
"""Test we can migrate event_types to the EventTypes table when the event_type is NULL."""
|
||||
instance = await async_setup_recorder_instance(hass)
|
||||
await async_wait_recording_done(hass)
|
||||
|
||||
def _insert_events():
|
||||
with session_scope(hass=hass) as session:
|
||||
session.add(
|
||||
Events(
|
||||
event_type="event_type_one",
|
||||
origin_idx=0,
|
||||
time_fired_ts=1.452529,
|
||||
),
|
||||
)
|
||||
session.add_all(
|
||||
Events(
|
||||
event_type=None,
|
||||
origin_idx=0,
|
||||
time_fired_ts=time + 1.452529,
|
||||
)
|
||||
for time in range(1000)
|
||||
)
|
||||
session.add(
|
||||
Events(
|
||||
event_type="event_type_one",
|
||||
origin_idx=0,
|
||||
time_fired_ts=2.452529,
|
||||
),
|
||||
)
|
||||
|
||||
await instance.async_add_executor_job(_insert_events)
|
||||
|
||||
await async_wait_recording_done(hass)
|
||||
# This is a threadsafe way to add a task to the recorder
|
||||
|
||||
instance.queue_task(EventTypeIDMigrationTask())
|
||||
await async_recorder_block_till_done(hass)
|
||||
await async_recorder_block_till_done(hass)
|
||||
|
||||
def _fetch_migrated_events():
|
||||
with session_scope(hass=hass) as session:
|
||||
events = (
|
||||
session.query(Events.event_id, Events.time_fired, EventTypes.event_type)
|
||||
.filter(
|
||||
Events.event_type_id.in_(
|
||||
select_event_type_ids(
|
||||
(
|
||||
"event_type_one",
|
||||
migration._EMPTY_EVENT_TYPE,
|
||||
)
|
||||
)
|
||||
)
|
||||
)
|
||||
.outerjoin(EventTypes, Events.event_type_id == EventTypes.event_type_id)
|
||||
.all()
|
||||
)
|
||||
assert len(events) == 1002
|
||||
result = {}
|
||||
for event in events:
|
||||
result.setdefault(event.event_type, []).append(
|
||||
{
|
||||
"event_id": event.event_id,
|
||||
"time_fired": event.time_fired,
|
||||
"event_type": event.event_type,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
events_by_type = await instance.async_add_executor_job(_fetch_migrated_events)
|
||||
assert len(events_by_type["event_type_one"]) == 2
|
||||
assert len(events_by_type[migration._EMPTY_EVENT_TYPE]) == 1000
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Fixtures for the Scrape integration."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Generator
|
||||
from typing import Any
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
import uuid
|
||||
|
||||
import pytest
|
||||
@@ -32,6 +33,16 @@ from . import MockRestData
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_setup_entry() -> Generator[AsyncMock, None, None]:
|
||||
"""Automatically path uuid generator."""
|
||||
with patch(
|
||||
"homeassistant.components.scrape.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
yield mock_setup_entry
|
||||
|
||||
|
||||
@pytest.fixture(name="get_config")
|
||||
async def get_config_to_integration_load() -> dict[str, Any]:
|
||||
"""Return default minimal configuration.
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
"""Test the Scrape config flow."""
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
from unittest.mock import AsyncMock, patch
|
||||
import uuid
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components.rest.data import DEFAULT_TIMEOUT
|
||||
from homeassistant.components.rest.schema import DEFAULT_METHOD
|
||||
from homeassistant.components.scrape import DOMAIN
|
||||
from homeassistant.components.scrape.config_flow import NONE_SENTINEL
|
||||
from homeassistant.components.scrape.const import (
|
||||
CONF_ENCODING,
|
||||
CONF_INDEX,
|
||||
@@ -15,14 +16,18 @@ from homeassistant.components.scrape.const import (
|
||||
DEFAULT_ENCODING,
|
||||
DEFAULT_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.components.sensor import CONF_STATE_CLASS
|
||||
from homeassistant.const import (
|
||||
CONF_DEVICE_CLASS,
|
||||
CONF_METHOD,
|
||||
CONF_NAME,
|
||||
CONF_PASSWORD,
|
||||
CONF_RESOURCE,
|
||||
CONF_TIMEOUT,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_UNIT_OF_MEASUREMENT,
|
||||
CONF_USERNAME,
|
||||
CONF_VALUE_TEMPLATE,
|
||||
CONF_VERIFY_SSL,
|
||||
)
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -34,7 +39,9 @@ from . import MockRestData
|
||||
from tests.common import MockConfigEntry
|
||||
|
||||
|
||||
async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None:
|
||||
async def test_form(
|
||||
hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test we get the form."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -46,10 +53,7 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None:
|
||||
with patch(
|
||||
"homeassistant.components.rest.RestData",
|
||||
return_value=get_data,
|
||||
) as mock_data, patch(
|
||||
"homeassistant.components.scrape.async_setup_entry",
|
||||
return_value=True,
|
||||
) as mock_setup_entry:
|
||||
) as mock_data:
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
@@ -66,6 +70,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None:
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0.0,
|
||||
CONF_DEVICE_CLASS: NONE_SENTINEL,
|
||||
CONF_STATE_CLASS: NONE_SENTINEL,
|
||||
CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@@ -92,7 +99,9 @@ async def test_form(hass: HomeAssistant, get_data: MockRestData) -> None:
|
||||
assert len(mock_setup_entry.mock_calls) == 1
|
||||
|
||||
|
||||
async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None:
|
||||
async def test_flow_fails(
|
||||
hass: HomeAssistant, get_data: MockRestData, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test config flow error."""
|
||||
|
||||
result = await hass.config_entries.flow.async_init(
|
||||
@@ -137,9 +146,6 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None:
|
||||
with patch(
|
||||
"homeassistant.components.rest.RestData",
|
||||
return_value=get_data,
|
||||
), patch(
|
||||
"homeassistant.components.scrape.async_setup_entry",
|
||||
return_value=True,
|
||||
):
|
||||
result3 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
@@ -157,6 +163,9 @@ async def test_flow_fails(hass: HomeAssistant, get_data: MockRestData) -> None:
|
||||
CONF_NAME: "Current version",
|
||||
CONF_SELECT: ".current-version h1",
|
||||
CONF_INDEX: 0.0,
|
||||
CONF_DEVICE_CLASS: NONE_SENTINEL,
|
||||
CONF_STATE_CLASS: NONE_SENTINEL,
|
||||
CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@@ -278,6 +287,9 @@ async def test_options_add_remove_sensor_flow(
|
||||
CONF_NAME: "Template",
|
||||
CONF_SELECT: "template",
|
||||
CONF_INDEX: 0.0,
|
||||
CONF_DEVICE_CLASS: NONE_SENTINEL,
|
||||
CONF_STATE_CLASS: NONE_SENTINEL,
|
||||
CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@@ -405,6 +417,9 @@ async def test_options_edit_sensor_flow(
|
||||
user_input={
|
||||
CONF_SELECT: "template",
|
||||
CONF_INDEX: 0.0,
|
||||
CONF_DEVICE_CLASS: NONE_SENTINEL,
|
||||
CONF_STATE_CLASS: NONE_SENTINEL,
|
||||
CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
@@ -434,3 +449,161 @@ async def test_options_edit_sensor_flow(
|
||||
# Check the state of the entity has changed as expected
|
||||
state = hass.states.get("sensor.current_version")
|
||||
assert state.state == "Trying to get"
|
||||
|
||||
|
||||
async def test_sensor_options_add_device_class(
|
||||
hass: HomeAssistant, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test options flow to edit a sensor."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
options={
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: DEFAULT_METHOD,
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
CONF_TIMEOUT: DEFAULT_TIMEOUT,
|
||||
CONF_ENCODING: DEFAULT_ENCODING,
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current Temp",
|
||||
CONF_SELECT: ".current-temp h3",
|
||||
CONF_INDEX: 0,
|
||||
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
}
|
||||
],
|
||||
},
|
||||
entry_id="1",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert result["type"] == FlowResultType.MENU
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"next_step_id": "select_edit_sensor"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "select_edit_sensor"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"index": "0"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "edit_sensor"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_SELECT: ".current-temp h3",
|
||||
CONF_INDEX: 0.0,
|
||||
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
|
||||
CONF_DEVICE_CLASS: "temperature",
|
||||
CONF_STATE_CLASS: "measurement",
|
||||
CONF_UNIT_OF_MEASUREMENT: "°C",
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10,
|
||||
CONF_ENCODING: "UTF-8",
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current Temp",
|
||||
CONF_SELECT: ".current-temp h3",
|
||||
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
|
||||
CONF_INDEX: 0,
|
||||
CONF_DEVICE_CLASS: "temperature",
|
||||
CONF_STATE_CLASS: "measurement",
|
||||
CONF_UNIT_OF_MEASUREMENT: "°C",
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
async def test_sensor_options_remove_device_class(
|
||||
hass: HomeAssistant, mock_setup_entry: AsyncMock
|
||||
) -> None:
|
||||
"""Test options flow to edit a sensor."""
|
||||
entry = MockConfigEntry(
|
||||
domain=DOMAIN,
|
||||
options={
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: DEFAULT_METHOD,
|
||||
CONF_VERIFY_SSL: DEFAULT_VERIFY_SSL,
|
||||
CONF_TIMEOUT: DEFAULT_TIMEOUT,
|
||||
CONF_ENCODING: DEFAULT_ENCODING,
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current Temp",
|
||||
CONF_SELECT: ".current-temp h3",
|
||||
CONF_INDEX: 0,
|
||||
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
|
||||
CONF_DEVICE_CLASS: "temperature",
|
||||
CONF_STATE_CLASS: "measurement",
|
||||
CONF_UNIT_OF_MEASUREMENT: "°C",
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
}
|
||||
],
|
||||
},
|
||||
entry_id="1",
|
||||
)
|
||||
entry.add_to_hass(hass)
|
||||
|
||||
result = await hass.config_entries.options.async_init(entry.entry_id)
|
||||
assert result["type"] == FlowResultType.MENU
|
||||
assert result["step_id"] == "init"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"next_step_id": "select_edit_sensor"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "select_edit_sensor"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
{"index": "0"},
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "edit_sensor"
|
||||
|
||||
result = await hass.config_entries.options.async_configure(
|
||||
result["flow_id"],
|
||||
user_input={
|
||||
CONF_SELECT: ".current-temp h3",
|
||||
CONF_INDEX: 0.0,
|
||||
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
|
||||
CONF_DEVICE_CLASS: NONE_SENTINEL,
|
||||
CONF_STATE_CLASS: NONE_SENTINEL,
|
||||
CONF_UNIT_OF_MEASUREMENT: NONE_SENTINEL,
|
||||
},
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
assert result["type"] == FlowResultType.CREATE_ENTRY
|
||||
assert result["data"] == {
|
||||
CONF_RESOURCE: "https://www.home-assistant.io",
|
||||
CONF_METHOD: "GET",
|
||||
CONF_VERIFY_SSL: True,
|
||||
CONF_TIMEOUT: 10,
|
||||
CONF_ENCODING: "UTF-8",
|
||||
"sensor": [
|
||||
{
|
||||
CONF_NAME: "Current Temp",
|
||||
CONF_SELECT: ".current-temp h3",
|
||||
CONF_VALUE_TEMPLATE: "{{ value.split(':')[1] }}",
|
||||
CONF_INDEX: 0,
|
||||
CONF_UNIQUE_ID: "3699ef88-69e6-11ed-a1eb-0242ac120002",
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
@@ -19,6 +19,18 @@ DATASET_1_REORDERED = (
|
||||
"10445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F801021234"
|
||||
)
|
||||
|
||||
DATASET_1_BAD_CHANNEL = (
|
||||
"0E080000000000010000000035060004001FFFE0020811111111222222220708FDAD70BF"
|
||||
"E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01"
|
||||
"0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8"
|
||||
)
|
||||
|
||||
DATASET_1_NO_CHANNEL = (
|
||||
"0E08000000000001000035060004001FFFE0020811111111222222220708FDAD70BF"
|
||||
"E5AA15DD051000112233445566778899AABBCCDDEEFF030E4F70656E54687265616444656D6F01"
|
||||
"0212340410445F2B5CA6F2A93A55CE570A70EFEECB0C0402A0F7F8"
|
||||
)
|
||||
|
||||
|
||||
async def test_add_invalid_dataset(hass: HomeAssistant) -> None:
|
||||
"""Test adding an invalid dataset."""
|
||||
@@ -109,6 +121,8 @@ async def test_dataset_properties(hass: HomeAssistant) -> None:
|
||||
{"source": "Google", "tlv": DATASET_1},
|
||||
{"source": "Multipan", "tlv": DATASET_2},
|
||||
{"source": "🎅", "tlv": DATASET_3},
|
||||
{"source": "test1", "tlv": DATASET_1_BAD_CHANNEL},
|
||||
{"source": "test2", "tlv": DATASET_1_NO_CHANNEL},
|
||||
]
|
||||
|
||||
for dataset in datasets:
|
||||
@@ -122,25 +136,40 @@ async def test_dataset_properties(hass: HomeAssistant) -> None:
|
||||
dataset_2 = dataset
|
||||
if dataset.source == "🎅":
|
||||
dataset_3 = dataset
|
||||
if dataset.source == "test1":
|
||||
dataset_4 = dataset
|
||||
if dataset.source == "test2":
|
||||
dataset_5 = dataset
|
||||
|
||||
dataset = store.async_get(dataset_1.id)
|
||||
assert dataset == dataset_1
|
||||
assert dataset.channel == 15
|
||||
assert dataset.extended_pan_id == "1111111122222222"
|
||||
assert dataset.network_name == "OpenThreadDemo"
|
||||
assert dataset.pan_id == "1234"
|
||||
|
||||
dataset = store.async_get(dataset_2.id)
|
||||
assert dataset == dataset_2
|
||||
assert dataset.channel == 15
|
||||
assert dataset.extended_pan_id == "1111111122222222"
|
||||
assert dataset.network_name == "HomeAssistant!"
|
||||
assert dataset.pan_id == "1234"
|
||||
|
||||
dataset = store.async_get(dataset_3.id)
|
||||
assert dataset == dataset_3
|
||||
assert dataset.channel == 15
|
||||
assert dataset.extended_pan_id == "1111111122222222"
|
||||
assert dataset.network_name == "~🐣🐥🐤~"
|
||||
assert dataset.pan_id == "1234"
|
||||
|
||||
dataset = store.async_get(dataset_4.id)
|
||||
assert dataset == dataset_4
|
||||
assert dataset.channel is None
|
||||
|
||||
dataset = store.async_get(dataset_5.id)
|
||||
assert dataset == dataset_5
|
||||
assert dataset.channel is None
|
||||
|
||||
|
||||
async def test_load_datasets(hass: HomeAssistant) -> None:
|
||||
"""Make sure that we can load/save data correctly."""
|
||||
|
||||
@@ -153,6 +153,7 @@ async def test_list_get_dataset(
|
||||
assert msg["result"] == {
|
||||
"datasets": [
|
||||
{
|
||||
"channel": 15,
|
||||
"created": dataset_1.created.isoformat(),
|
||||
"dataset_id": dataset_1.id,
|
||||
"extended_pan_id": "1111111122222222",
|
||||
@@ -162,6 +163,7 @@ async def test_list_get_dataset(
|
||||
"source": "Google",
|
||||
},
|
||||
{
|
||||
"channel": 15,
|
||||
"created": dataset_2.created.isoformat(),
|
||||
"dataset_id": dataset_2.id,
|
||||
"extended_pan_id": "1111111122222222",
|
||||
@@ -171,6 +173,7 @@ async def test_list_get_dataset(
|
||||
"source": "Multipan",
|
||||
},
|
||||
{
|
||||
"channel": 15,
|
||||
"created": dataset_3.created.isoformat(),
|
||||
"dataset_id": dataset_3.id,
|
||||
"extended_pan_id": "1111111122222222",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import zigpy.backups
|
||||
import zigpy.state
|
||||
|
||||
from homeassistant.components import zha
|
||||
@@ -36,7 +37,9 @@ async def test_async_get_network_settings_inactive(
|
||||
gateway = api._get_gateway(hass)
|
||||
await zha.async_unload_entry(hass, gateway.config_entry)
|
||||
|
||||
zigpy_app_controller.state.network_info.channel = 20
|
||||
backup = zigpy.backups.NetworkBackup()
|
||||
backup.network_info.channel = 20
|
||||
zigpy_app_controller.backups.backups.append(backup)
|
||||
|
||||
with patch(
|
||||
"bellows.zigbee.application.ControllerApplication.__new__",
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
"""Test ZHA base channel module."""
|
||||
|
||||
from homeassistant.components.zha.core.channels.base import parse_and_log_command
|
||||
|
||||
from tests.components.zha.test_channels import ( # noqa: F401
|
||||
channel_pool,
|
||||
poll_control_ch,
|
||||
zigpy_coordinator_device,
|
||||
)
|
||||
|
||||
|
||||
def test_parse_and_log_command(poll_control_ch): # noqa: F811
|
||||
"""Test that `parse_and_log_command` correctly parses a known command."""
|
||||
assert parse_and_log_command(poll_control_ch, 0x00, 0x01, []) == "fast_poll_stop"
|
||||
|
||||
|
||||
def test_parse_and_log_command_unknown(poll_control_ch): # noqa: F811
|
||||
"""Test that `parse_and_log_command` correctly parses an unknown command."""
|
||||
assert parse_and_log_command(poll_control_ch, 0x00, 0xAB, []) == "0xAB"
|
||||
@@ -8,12 +8,15 @@ import zigpy.zcl.clusters.security as security
|
||||
|
||||
from homeassistant.const import STATE_OFF, STATE_ON, STATE_UNAVAILABLE, Platform
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers import restore_state
|
||||
from homeassistant.util import dt as dt_util
|
||||
|
||||
from .common import (
|
||||
async_enable_traffic,
|
||||
async_test_rejoin,
|
||||
find_entity_id,
|
||||
send_attributes_report,
|
||||
update_attribute_cache,
|
||||
)
|
||||
from .conftest import SIG_EP_INPUT, SIG_EP_OUTPUT, SIG_EP_PROFILE, SIG_EP_TYPE
|
||||
|
||||
@@ -120,3 +123,92 @@ async def test_binary_sensor(
|
||||
# test rejoin
|
||||
await async_test_rejoin(hass, zigpy_device, [cluster], reporting)
|
||||
assert hass.states.get(entity_id).state == STATE_OFF
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def core_rs(hass_storage):
|
||||
"""Core.restore_state fixture."""
|
||||
|
||||
def _storage(entity_id, attributes, state):
|
||||
now = dt_util.utcnow().isoformat()
|
||||
|
||||
hass_storage[restore_state.STORAGE_KEY] = {
|
||||
"version": restore_state.STORAGE_VERSION,
|
||||
"key": restore_state.STORAGE_KEY,
|
||||
"data": [
|
||||
{
|
||||
"state": {
|
||||
"entity_id": entity_id,
|
||||
"state": str(state),
|
||||
"attributes": attributes,
|
||||
"last_changed": now,
|
||||
"last_updated": now,
|
||||
"context": {
|
||||
"id": "3c2243ff5f30447eb12e7348cfd5b8ff",
|
||||
"user_id": None,
|
||||
},
|
||||
},
|
||||
"last_seen": now,
|
||||
}
|
||||
],
|
||||
}
|
||||
return
|
||||
|
||||
return _storage
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"restored_state",
|
||||
[
|
||||
STATE_ON,
|
||||
STATE_OFF,
|
||||
],
|
||||
)
|
||||
async def test_binary_sensor_migration_not_migrated(
|
||||
hass: HomeAssistant,
|
||||
zigpy_device_mock,
|
||||
core_rs,
|
||||
zha_device_restored,
|
||||
restored_state,
|
||||
) -> None:
|
||||
"""Test temporary ZHA IasZone binary_sensor migration to zigpy cache."""
|
||||
|
||||
entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone"
|
||||
core_rs(entity_id, state=restored_state, attributes={}) # migration sensor state
|
||||
|
||||
zigpy_device = zigpy_device_mock(DEVICE_IAS)
|
||||
zha_device = await zha_device_restored(zigpy_device)
|
||||
entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass)
|
||||
|
||||
assert entity_id is not None
|
||||
assert hass.states.get(entity_id).state == restored_state
|
||||
|
||||
# confirm migration extra state attribute was set to True
|
||||
assert hass.states.get(entity_id).attributes["migrated_to_cache"]
|
||||
|
||||
|
||||
async def test_binary_sensor_migration_already_migrated(
|
||||
hass: HomeAssistant,
|
||||
zigpy_device_mock,
|
||||
core_rs,
|
||||
zha_device_restored,
|
||||
) -> None:
|
||||
"""Test temporary ZHA IasZone binary_sensor migration doesn't migrate multiple times."""
|
||||
|
||||
entity_id = "binary_sensor.fakemanufacturer_fakemodel_iaszone"
|
||||
core_rs(entity_id, state=STATE_OFF, attributes={"migrated_to_cache": True})
|
||||
|
||||
zigpy_device = zigpy_device_mock(DEVICE_IAS)
|
||||
|
||||
cluster = zigpy_device.endpoints.get(1).ias_zone
|
||||
cluster.PLUGGED_ATTR_READS = {
|
||||
"zone_status": security.IasZone.ZoneStatus.Alarm_1,
|
||||
}
|
||||
update_attribute_cache(cluster)
|
||||
|
||||
zha_device = await zha_device_restored(zigpy_device)
|
||||
entity_id = await find_entity_id(Platform.BINARY_SENSOR, zha_device, hass)
|
||||
|
||||
assert entity_id is not None
|
||||
assert hass.states.get(entity_id).state == STATE_ON # matches attribute cache
|
||||
assert hass.states.get(entity_id).attributes["migrated_to_cache"]
|
||||
|
||||
@@ -6,6 +6,7 @@ import zigpy.profiles.zha as zha
|
||||
import zigpy.zcl.clusters.security as security
|
||||
|
||||
from homeassistant.components.diagnostics import REDACTED
|
||||
from homeassistant.components.zha.core.const import DATA_ZHA, DATA_ZHA_GATEWAY
|
||||
from homeassistant.components.zha.core.device import ZHADevice
|
||||
from homeassistant.components.zha.diagnostics import KEYS_TO_REDACT
|
||||
from homeassistant.const import Platform
|
||||
@@ -62,14 +63,25 @@ async def test_diagnostics_for_config_entry(
|
||||
) -> None:
|
||||
"""Test diagnostics for config entry."""
|
||||
await zha_device_joined(zigpy_device)
|
||||
diagnostics_data = await get_diagnostics_for_config_entry(
|
||||
hass, hass_client, config_entry
|
||||
)
|
||||
assert diagnostics_data
|
||||
|
||||
gateway = hass.data[DATA_ZHA][DATA_ZHA_GATEWAY]
|
||||
scan = {c: c for c in range(11, 26 + 1)}
|
||||
|
||||
with patch.object(gateway.application_controller, "energy_scan", return_value=scan):
|
||||
diagnostics_data = await get_diagnostics_for_config_entry(
|
||||
hass, hass_client, config_entry
|
||||
)
|
||||
|
||||
for key in CONFIG_ENTRY_DIAGNOSTICS_KEYS:
|
||||
assert key in diagnostics_data
|
||||
assert diagnostics_data[key] is not None
|
||||
|
||||
# Energy scan results are presented as a percentage. JSON object keys also must be
|
||||
# strings, not integers.
|
||||
assert diagnostics_data["energy_scan"] == {
|
||||
str(k): 100 * v / 255 for k, v in scan.items()
|
||||
}
|
||||
|
||||
|
||||
async def test_diagnostics_for_device(
|
||||
hass: HomeAssistant,
|
||||
|
||||
@@ -323,3 +323,32 @@ async def test_gateway_initialize_bellows_thread(
|
||||
await zha_gateway.async_initialize()
|
||||
|
||||
assert mock_new.mock_calls[0].args[0]["use_thread"] is thread_state
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("device_path", "config_override", "expected_channel"),
|
||||
[
|
||||
("/dev/ttyUSB0", {}, None),
|
||||
("socket://192.168.1.123:9999", {}, None),
|
||||
("socket://192.168.1.123:9999", {"network": {"channel": 20}}, 20),
|
||||
("socket://core-silabs-multiprotocol:9999", {}, 15),
|
||||
("socket://core-silabs-multiprotocol:9999", {"network": {"channel": 20}}, 20),
|
||||
],
|
||||
)
|
||||
async def test_gateway_force_multi_pan_channel(
|
||||
device_path: str,
|
||||
config_override: dict,
|
||||
expected_channel: int | None,
|
||||
hass: HomeAssistant,
|
||||
coordinator,
|
||||
) -> None:
|
||||
"""Test ZHA disabling the UART thread when connecting to a TCP coordinator."""
|
||||
zha_gateway = get_zha_gateway(hass)
|
||||
assert zha_gateway is not None
|
||||
|
||||
zha_gateway.config_entry.data = dict(zha_gateway.config_entry.data)
|
||||
zha_gateway.config_entry.data["device"]["path"] = device_path
|
||||
zha_gateway._config.setdefault("zigpy_config", {}).update(config_override)
|
||||
|
||||
_, config = zha_gateway.get_application_controller_data()
|
||||
assert config["network"]["channel"] == expected_channel
|
||||
|
||||
@@ -1463,6 +1463,11 @@ def test_is_hidden_entity(
|
||||
hass,
|
||||
).async_render()
|
||||
|
||||
assert not template.Template(
|
||||
f"{{{{ ['{visible_entity.entity_id}'] | select('is_hidden_entity') | first }}}}",
|
||||
hass,
|
||||
).async_render()
|
||||
|
||||
|
||||
def test_is_state(hass: HomeAssistant) -> None:
|
||||
"""Test is_state method."""
|
||||
|
||||
Reference in New Issue
Block a user