Compare commits

..

33 Commits

Author SHA1 Message Date
Paulus Schoutsen 03f085d7be Bumped version to 2023.4.0b3 2023-03-31 15:41:37 -04:00
Raman Gupta b3348c3e6f Bump zwave-js-server-python to 0.47.3 (#90606)
* Bump zwave-js-server-python to 0.47.2

* Bump zwave-js-server-python to 0.47.3
2023-03-31 15:41:33 -04:00
puddly 590db0fa74 Perform an energy scan when downloading ZHA diagnostics (#90605) 2023-03-31 15:41:32 -04:00
puddly f56ccf90d9 Fix ZHA definition error on received command (#90602)
* Fix use of deprecated command schema access

* Add a unit test
2023-03-31 15:41:31 -04:00
Bram Kragten c63f8e714e Update frontend to 20230331.0 (#90594) 2023-03-31 15:41:30 -04:00
starkillerOG a20771f571 Bump reolink-aio to 0.5.9 (#90590) 2023-03-31 15:41:29 -04:00
Franck Nijhof 2d482f1f57 Raise on invalid (dis)arm code in manual mqtt alarm (#90584) 2023-03-31 15:41:28 -04:00
Erik Montnemery 499962f4ee Tweak yalexs_ble translations (#90582) 2023-03-31 15:41:27 -04:00
Franck Nijhof 88a407361c Raise on invalid (dis)arm code in manual alarm (#90579) 2023-03-31 15:41:26 -04:00
Franck Nijhof 89dc6db5a7 Add arming/disarming state to Verisure (#90577) 2023-03-31 15:41:25 -04:00
J. Nick Koston de9e7e47fe Make sonos activity check a background task (#90553)
Ensures the task is canceled at shutdown if the device
is offline and the ping is still in progress
2023-03-31 15:41:24 -04:00
epenet ab66664f20 Allow removal of sensor settings in scrape (#90412)
* Allow removal of sensor settings in scrape

* Adjust

* Adjust

* Add comment

* Simplify

* Simplify

* Adjust

* Don't allow empty string

* Only allow None

* Use default as None

* Use sentinel "none"

* Not needed

* Adjust unit of measurement

* Add translation keys for "none"

* Use translations

* Sort

* Add enum and timestamp

* Use translation references

* Remove default and set suggested_values

* Disallow enum device class

* Adjust tests

* Adjust _strip_sentinel
2023-03-31 15:41:23 -04:00
Paulus Schoutsen e7e2532c68 Bumped version to 2023.4.0b2 2023-03-30 20:55:55 -04:00
puddly 4bf10c01f0 Bump ZHA dependencies (#90547)
* Bump ZHA dependencies

* Ensure the network is formed on channel 15 when multi-PAN is in use
2023-03-30 20:55:37 -04:00
J. Nick Koston aad1f4b766 Handle garbage in the context_id column during migration (#90544)
* Handle garbage in the context_id column during migration

* Update homeassistant/components/recorder/migration.py

* lint
2023-03-30 20:55:36 -04:00
J. Nick Koston e32d89215d Fix migration when encountering a NULL entity_id/event_type (#90542)
* Fix migration when encountering a NULL entity_id/event_type

reported in #beta on discord

* simplify
2023-03-30 20:55:36 -04:00
Franck Nijhof 9478518937 Add entity name translations to LaMetric (#90538)
* Add entity name translations to LaMetric

* Consistency
2023-03-30 20:55:35 -04:00
Bram Kragten 8a99d2a566 Update frontend to 20230330.0 (#90524) 2023-03-30 20:55:34 -04:00
TheJulianJES 38aff23be5 Migrate old ZHA IasZone sensor state to zigpy cache (#90508)
* Migrate old ZHA IasZone sensor state to zigpy cache

* Use correct type for ZoneStatus

* Test that migration happens

* Test that migration only happens once

* Fix parametrize
2023-03-30 20:55:33 -04:00
Paulus Schoutsen 705e68be9e Bumped version to 2023.4.0b1 2023-03-30 10:40:19 -04:00
Franck Nijhof 4a319c73ab Add a device to the sun (#90517) 2023-03-30 10:40:12 -04:00
Paulus Schoutsen 576780be74 Unregister webhook when registering webhook with nuki fials (#90514) 2023-03-30 10:40:11 -04:00
Petro31 01734c0dab Fix for is_hidden_entity when using it in select, selectattr, reject, and rejectattr (#90512)
fix
2023-03-30 10:40:10 -04:00
Erik Montnemery 2157a4d0fc Include channel in response to WS thread/list_datasets (#90493) 2023-03-30 10:40:09 -04:00
Paulus Schoutsen b83cb5d1b1 OpenAI to rely on built-in areas variable (#90481) 2023-03-30 10:40:08 -04:00
J. Nick Koston 2a627e63f1 Fix filesize doing blocking I/O in the event loop (#90479)
Fix filesize doing I/O in the event loop
2023-03-30 10:40:06 -04:00
puddly 30af4c769e Correctly load ZHA settings from API when integration is not running (#90476)
Correctly load settings from the zigpy database when ZHA is not running
2023-03-30 10:40:05 -04:00
epenet 02f108498c Add missing strings to sensor integration (#90475)
* Add missing strings to sensor integration

* Enumeration

* Apply suggestion

Co-authored-by: Franck Nijhof <frenck@frenck.nl>

---------

Co-authored-by: Franck Nijhof <frenck@frenck.nl>
2023-03-30 10:40:04 -04:00
J. Nick Koston 9f3c0fa927 Bump yalexs-ble to 2.1.14 (#90474)
changelog: https://github.com/bdraco/yalexs-ble/compare/v2.1.13...v2.1.14

reduces ble traffic (fixes a bug were we were checking when we did not need to be)
2023-03-30 10:40:03 -04:00
Guido Schmitz b5811ad1c2 Add entity name translations for devolo Home Network (#90471) 2023-03-30 10:40:02 -04:00
starkillerOG baccbd98c7 Bump reolink-aio to 0.5.8 (#90467) 2023-03-30 10:40:01 -04:00
Thijs W 9d116799d6 Add missing strings in frontier_silicon (#90446)
Improve confirm message for ssdp flow
2023-03-30 10:40:00 -04:00
RenierM26 e877fd6682 Use auth token in Ezviz (#54663)
* Initial commit

* Revert "Initial commit"

This reverts commit 452027f1a3c1be186cedd4115cea6928917c9467.

* Change ezviz to token auth

* Bump API version.

* Add fix for token expired. Fix options update and unload.

* Fix tests (PLATFORM to PLATFORM_BY_TYPE)

* Uses and stores token only, added reauth step when token expires.

* Add tests MFA code exceptions.

* Fix tests.

* Remove redundant try/except blocks.

* Rebase fixes.

* Fix errors in reauth config flow

* Implement recommendations

* Fix typing error in config_flow

* Fix tests after rebase, readd camera check on init

* Change to platform setup

* Cleanup init.

* Test for MFA required under user form

* Remove useless if block.

* Fix formating after rebase

* Fix formating.

* No longer stored in the repository

---------

Co-authored-by: Paulus Schoutsen <paulus@home-assistant.io>
2023-03-30 10:39:58 -04:00
70 changed files with 1575 additions and 450 deletions
@@ -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]
+72 -65
View File
@@ -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
+3 -11
View File
@@ -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,
)
)
+182 -104
View File
@@ -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)
+3 -2
View File
@@ -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"
+14 -6
View File
@@ -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
+12 -2
View File
@@ -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": {
+7 -12
View File
@@ -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": {
+4 -4
View File
@@ -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(),
+1 -2
View File
@@ -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"
}
}
}
}
+1 -1
View File
@@ -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]:
+24 -19
View File
@@ -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 %}
+30 -14
View File
@@ -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"]
}
+32 -7
View File
@@ -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"
},
+8 -1
View File
@@ -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:
+8
View File
@@ -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%]",
+8 -15
View File
@@ -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
+34 -1
View File
@@ -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"),
+5 -5
View File
@@ -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",
+1 -1
View File
@@ -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)
+5 -1
View File
@@ -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)
+11 -4
View File
@@ -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)
+1 -1
View File
@@ -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
View File
@@ -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"
+9 -9
View File
@@ -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
+9 -9
View File
@@ -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
+24 -2
View File
@@ -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(
+7 -1
View File
@@ -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
+218 -24
View File
@@ -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
+191 -4
View File
@@ -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
+12 -1
View File
@@ -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.
+183 -10
View File
@@ -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",
+4 -1
View File
@@ -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__",
+19
View File
@@ -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"]
+16 -4
View File
@@ -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,
+29
View File
@@ -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
+5
View File
@@ -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."""