mirror of
https://github.com/home-assistant/core.git
synced 2025-07-29 16:17:20 +00:00
2023.5.2 (#92610)
This commit is contained in:
commit
e904edb12e
@ -3,7 +3,7 @@ from abc import ABC, abstractmethod
|
|||||||
import asyncio
|
import asyncio
|
||||||
import logging
|
import logging
|
||||||
|
|
||||||
from homeassistant.core import CALLBACK_TYPE, callback
|
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||||
from homeassistant.helpers.storage import Store
|
from homeassistant.helpers.storage import Store
|
||||||
|
|
||||||
from .const import DOMAIN
|
from .const import DOMAIN
|
||||||
@ -19,7 +19,7 @@ class AbstractConfig(ABC):
|
|||||||
|
|
||||||
_unsub_proactive_report: asyncio.Task[CALLBACK_TYPE] | None = None
|
_unsub_proactive_report: asyncio.Task[CALLBACK_TYPE] | None = None
|
||||||
|
|
||||||
def __init__(self, hass):
|
def __init__(self, hass: HomeAssistant) -> None:
|
||||||
"""Initialize abstract config."""
|
"""Initialize abstract config."""
|
||||||
self.hass = hass
|
self.hass = hass
|
||||||
self._store = None
|
self._store = None
|
||||||
|
@ -199,14 +199,10 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
|||||||
# Don't migrate if there's a YAML config
|
# Don't migrate if there's a YAML config
|
||||||
return
|
return
|
||||||
|
|
||||||
for state in self.hass.states.async_all():
|
for entity_id in {
|
||||||
async_expose_entity(
|
*self.hass.states.async_entity_ids(),
|
||||||
self.hass,
|
*self._prefs.alexa_entity_configs,
|
||||||
CLOUD_ALEXA,
|
}:
|
||||||
state.entity_id,
|
|
||||||
self._should_expose_legacy(state.entity_id),
|
|
||||||
)
|
|
||||||
for entity_id in self._prefs.alexa_entity_configs:
|
|
||||||
async_expose_entity(
|
async_expose_entity(
|
||||||
self.hass,
|
self.hass,
|
||||||
CLOUD_ALEXA,
|
CLOUD_ALEXA,
|
||||||
@ -220,8 +216,18 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
|||||||
|
|
||||||
async def on_hass_started(hass):
|
async def on_hass_started(hass):
|
||||||
if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION:
|
if self._prefs.alexa_settings_version != ALEXA_SETTINGS_VERSION:
|
||||||
if self._prefs.alexa_settings_version < 2:
|
if self._prefs.alexa_settings_version < 2 or (
|
||||||
|
# Recover from a bug we had in 2023.5.0 where entities didn't get exposed
|
||||||
|
self._prefs.alexa_settings_version < 3
|
||||||
|
and not any(
|
||||||
|
settings.get("should_expose", False)
|
||||||
|
for settings in async_get_assistant_settings(
|
||||||
|
hass, CLOUD_ALEXA
|
||||||
|
).values()
|
||||||
|
)
|
||||||
|
):
|
||||||
self._migrate_alexa_entity_settings_v1()
|
self._migrate_alexa_entity_settings_v1()
|
||||||
|
|
||||||
await self._prefs.async_update(
|
await self._prefs.async_update(
|
||||||
alexa_settings_version=ALEXA_SETTINGS_VERSION
|
alexa_settings_version=ALEXA_SETTINGS_VERSION
|
||||||
)
|
)
|
||||||
|
@ -12,6 +12,7 @@ from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN
|
|||||||
from homeassistant.components.google_assistant.helpers import AbstractConfig
|
from homeassistant.components.google_assistant.helpers import AbstractConfig
|
||||||
from homeassistant.components.homeassistant.exposed_entities import (
|
from homeassistant.components.homeassistant.exposed_entities import (
|
||||||
async_expose_entity,
|
async_expose_entity,
|
||||||
|
async_get_assistant_settings,
|
||||||
async_get_entity_settings,
|
async_get_entity_settings,
|
||||||
async_listen_entity_updates,
|
async_listen_entity_updates,
|
||||||
async_set_assistant_option,
|
async_set_assistant_option,
|
||||||
@ -175,23 +176,10 @@ class CloudGoogleConfig(AbstractConfig):
|
|||||||
# Don't migrate if there's a YAML config
|
# Don't migrate if there's a YAML config
|
||||||
return
|
return
|
||||||
|
|
||||||
for state in self.hass.states.async_all():
|
for entity_id in {
|
||||||
entity_id = state.entity_id
|
*self.hass.states.async_entity_ids(),
|
||||||
async_expose_entity(
|
*self._prefs.google_entity_configs,
|
||||||
self.hass,
|
}:
|
||||||
CLOUD_GOOGLE,
|
|
||||||
entity_id,
|
|
||||||
self._should_expose_legacy(entity_id),
|
|
||||||
)
|
|
||||||
if _2fa_disabled := (self._2fa_disabled_legacy(entity_id) is not None):
|
|
||||||
async_set_assistant_option(
|
|
||||||
self.hass,
|
|
||||||
CLOUD_GOOGLE,
|
|
||||||
entity_id,
|
|
||||||
PREF_DISABLE_2FA,
|
|
||||||
_2fa_disabled,
|
|
||||||
)
|
|
||||||
for entity_id in self._prefs.google_entity_configs:
|
|
||||||
async_expose_entity(
|
async_expose_entity(
|
||||||
self.hass,
|
self.hass,
|
||||||
CLOUD_GOOGLE,
|
CLOUD_GOOGLE,
|
||||||
@ -213,8 +201,18 @@ class CloudGoogleConfig(AbstractConfig):
|
|||||||
|
|
||||||
async def on_hass_started(hass: HomeAssistant) -> None:
|
async def on_hass_started(hass: HomeAssistant) -> None:
|
||||||
if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION:
|
if self._prefs.google_settings_version != GOOGLE_SETTINGS_VERSION:
|
||||||
if self._prefs.google_settings_version < 2:
|
if self._prefs.google_settings_version < 2 or (
|
||||||
|
# Recover from a bug we had in 2023.5.0 where entities didn't get exposed
|
||||||
|
self._prefs.google_settings_version < 3
|
||||||
|
and not any(
|
||||||
|
settings.get("should_expose", False)
|
||||||
|
for settings in async_get_assistant_settings(
|
||||||
|
hass, CLOUD_GOOGLE
|
||||||
|
).values()
|
||||||
|
)
|
||||||
|
):
|
||||||
self._migrate_google_entity_settings_v1()
|
self._migrate_google_entity_settings_v1()
|
||||||
|
|
||||||
await self._prefs.async_update(
|
await self._prefs.async_update(
|
||||||
google_settings_version=GOOGLE_SETTINGS_VERSION
|
google_settings_version=GOOGLE_SETTINGS_VERSION
|
||||||
)
|
)
|
||||||
|
@ -41,8 +41,8 @@ STORAGE_KEY = DOMAIN
|
|||||||
STORAGE_VERSION = 1
|
STORAGE_VERSION = 1
|
||||||
STORAGE_VERSION_MINOR = 2
|
STORAGE_VERSION_MINOR = 2
|
||||||
|
|
||||||
ALEXA_SETTINGS_VERSION = 2
|
ALEXA_SETTINGS_VERSION = 3
|
||||||
GOOGLE_SETTINGS_VERSION = 2
|
GOOGLE_SETTINGS_VERSION = 3
|
||||||
|
|
||||||
|
|
||||||
class CloudPreferencesStore(Store):
|
class CloudPreferencesStore(Store):
|
||||||
|
@ -15,5 +15,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/elkm1",
|
"documentation": "https://www.home-assistant.io/integrations/elkm1",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["elkm1_lib"],
|
"loggers": ["elkm1_lib"],
|
||||||
"requirements": ["elkm1-lib==2.2.1"]
|
"requirements": ["elkm1-lib==2.2.2"]
|
||||||
}
|
}
|
||||||
|
@ -25,6 +25,7 @@ from aioesphomeapi import (
|
|||||||
NumberInfo,
|
NumberInfo,
|
||||||
SelectInfo,
|
SelectInfo,
|
||||||
SensorInfo,
|
SensorInfo,
|
||||||
|
SensorState,
|
||||||
SwitchInfo,
|
SwitchInfo,
|
||||||
TextSensorInfo,
|
TextSensorInfo,
|
||||||
UserService,
|
UserService,
|
||||||
@ -240,9 +241,18 @@ class RuntimeEntryData:
|
|||||||
current_state_by_type = self.state[state_type]
|
current_state_by_type = self.state[state_type]
|
||||||
current_state = current_state_by_type.get(key, _SENTINEL)
|
current_state = current_state_by_type.get(key, _SENTINEL)
|
||||||
subscription_key = (state_type, key)
|
subscription_key = (state_type, key)
|
||||||
if current_state == state and subscription_key not in stale_state:
|
if (
|
||||||
|
current_state == state
|
||||||
|
and subscription_key not in stale_state
|
||||||
|
and not (
|
||||||
|
type(state) is SensorState # pylint: disable=unidiomatic-typecheck
|
||||||
|
and (platform_info := self.info.get(Platform.SENSOR))
|
||||||
|
and (entity_info := platform_info.get(state.key))
|
||||||
|
and (cast(SensorInfo, entity_info)).force_update
|
||||||
|
)
|
||||||
|
):
|
||||||
_LOGGER.debug(
|
_LOGGER.debug(
|
||||||
"%s: ignoring duplicate update with and key %s: %s",
|
"%s: ignoring duplicate update with key %s: %s",
|
||||||
self.name,
|
self.name,
|
||||||
key,
|
key,
|
||||||
state,
|
state,
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["aioesphomeapi", "noiseprotocol"],
|
"loggers": ["aioesphomeapi", "noiseprotocol"],
|
||||||
"requirements": [
|
"requirements": [
|
||||||
"aioesphomeapi==13.7.2",
|
"aioesphomeapi==13.7.3",
|
||||||
"bluetooth-data-tools==0.4.0",
|
"bluetooth-data-tools==0.4.0",
|
||||||
"esphome-dashboard-api==1.2.3"
|
"esphome-dashboard-api==1.2.3"
|
||||||
],
|
],
|
||||||
|
@ -20,5 +20,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||||
"integration_type": "system",
|
"integration_type": "system",
|
||||||
"quality_scale": "internal",
|
"quality_scale": "internal",
|
||||||
"requirements": ["home-assistant-frontend==20230503.2"]
|
"requirements": ["home-assistant-frontend==20230503.3"]
|
||||||
}
|
}
|
||||||
|
@ -590,7 +590,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
|||||||
await async_setup_addon_panel(hass, hassio)
|
await async_setup_addon_panel(hass, hassio)
|
||||||
|
|
||||||
# Setup hardware integration for the detected board type
|
# Setup hardware integration for the detected board type
|
||||||
async def _async_setup_hardware_integration(hass):
|
async def _async_setup_hardware_integration(_: datetime) -> None:
|
||||||
"""Set up hardaware integration for the detected board type."""
|
"""Set up hardaware integration for the detected board type."""
|
||||||
if (os_info := get_os_info(hass)) is None:
|
if (os_info := get_os_info(hass)) is None:
|
||||||
# os info not yet fetched from supervisor, retry later
|
# os info not yet fetched from supervisor, retry later
|
||||||
@ -610,7 +610,7 @@ async def async_setup(hass: HomeAssistant, config: ConfigType) -> bool: # noqa:
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
await _async_setup_hardware_integration(hass)
|
await _async_setup_hardware_integration(datetime.now())
|
||||||
|
|
||||||
hass.async_create_task(
|
hass.async_create_task(
|
||||||
hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"})
|
hass.config_entries.flow.async_init(DOMAIN, context={"source": "system"})
|
||||||
|
@ -266,7 +266,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
|
|||||||
netatmo_name="power",
|
netatmo_name="power",
|
||||||
entity_registry_enabled_default=True,
|
entity_registry_enabled_default=True,
|
||||||
native_unit_of_measurement=UnitOfPower.WATT,
|
native_unit_of_measurement=UnitOfPower.WATT,
|
||||||
state_class=SensorStateClass.TOTAL,
|
state_class=SensorStateClass.MEASUREMENT,
|
||||||
device_class=SensorDeviceClass.POWER,
|
device_class=SensorDeviceClass.POWER,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
@ -34,7 +34,7 @@ class RebootButton(ONVIFBaseEntity, ButtonEntity):
|
|||||||
|
|
||||||
async def async_press(self) -> None:
|
async def async_press(self) -> None:
|
||||||
"""Send out a SystemReboot command."""
|
"""Send out a SystemReboot command."""
|
||||||
device_mgmt = self.device.device.create_devicemgmt_service()
|
device_mgmt = await self.device.device.create_devicemgmt_service()
|
||||||
await device_mgmt.SystemReboot()
|
await device_mgmt.SystemReboot()
|
||||||
|
|
||||||
|
|
||||||
|
@ -275,7 +275,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
|
|
||||||
try:
|
try:
|
||||||
await device.update_xaddrs()
|
await device.update_xaddrs()
|
||||||
device_mgmt = device.create_devicemgmt_service()
|
device_mgmt = await device.create_devicemgmt_service()
|
||||||
# Get the MAC address to use as the unique ID for the config flow
|
# Get the MAC address to use as the unique ID for the config flow
|
||||||
if not self.device_id:
|
if not self.device_id:
|
||||||
try:
|
try:
|
||||||
@ -314,7 +314,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
# Verify there is an H264 profile
|
# Verify there is an H264 profile
|
||||||
media_service = device.create_media_service()
|
media_service = await device.create_media_service()
|
||||||
profiles = await media_service.GetProfiles()
|
profiles = await media_service.GetProfiles()
|
||||||
except AttributeError: # Likely an empty document or 404 from the wrong port
|
except AttributeError: # Likely an empty document or 404 from the wrong port
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
|
@ -136,7 +136,7 @@ class ONVIFDevice:
|
|||||||
|
|
||||||
if self.capabilities.ptz:
|
if self.capabilities.ptz:
|
||||||
LOGGER.debug("%s: creating PTZ service", self.name)
|
LOGGER.debug("%s: creating PTZ service", self.name)
|
||||||
self.device.create_ptz_service()
|
await self.device.create_ptz_service()
|
||||||
|
|
||||||
# Determine max resolution from profiles
|
# Determine max resolution from profiles
|
||||||
self.max_resolution = max(
|
self.max_resolution = max(
|
||||||
@ -159,7 +159,7 @@ class ONVIFDevice:
|
|||||||
|
|
||||||
async def async_manually_set_date_and_time(self) -> None:
|
async def async_manually_set_date_and_time(self) -> None:
|
||||||
"""Set Date and Time Manually using SetSystemDateAndTime command."""
|
"""Set Date and Time Manually using SetSystemDateAndTime command."""
|
||||||
device_mgmt = self.device.create_devicemgmt_service()
|
device_mgmt = await self.device.create_devicemgmt_service()
|
||||||
|
|
||||||
# Retrieve DateTime object from camera to use as template for Set operation
|
# Retrieve DateTime object from camera to use as template for Set operation
|
||||||
device_time = await device_mgmt.GetSystemDateAndTime()
|
device_time = await device_mgmt.GetSystemDateAndTime()
|
||||||
@ -202,7 +202,7 @@ class ONVIFDevice:
|
|||||||
async def async_check_date_and_time(self) -> None:
|
async def async_check_date_and_time(self) -> None:
|
||||||
"""Warns if device and system date not synced."""
|
"""Warns if device and system date not synced."""
|
||||||
LOGGER.debug("%s: Setting up the ONVIF device management service", self.name)
|
LOGGER.debug("%s: Setting up the ONVIF device management service", self.name)
|
||||||
device_mgmt = self.device.create_devicemgmt_service()
|
device_mgmt = await self.device.create_devicemgmt_service()
|
||||||
system_date = dt_util.utcnow()
|
system_date = dt_util.utcnow()
|
||||||
|
|
||||||
LOGGER.debug("%s: Retrieving current device date/time", self.name)
|
LOGGER.debug("%s: Retrieving current device date/time", self.name)
|
||||||
@ -285,7 +285,7 @@ class ONVIFDevice:
|
|||||||
|
|
||||||
async def async_get_device_info(self) -> DeviceInfo:
|
async def async_get_device_info(self) -> DeviceInfo:
|
||||||
"""Obtain information about this device."""
|
"""Obtain information about this device."""
|
||||||
device_mgmt = self.device.create_devicemgmt_service()
|
device_mgmt = await self.device.create_devicemgmt_service()
|
||||||
manufacturer = None
|
manufacturer = None
|
||||||
model = None
|
model = None
|
||||||
firmware_version = None
|
firmware_version = None
|
||||||
@ -331,7 +331,7 @@ class ONVIFDevice:
|
|||||||
"""Obtain information about the available services on the device."""
|
"""Obtain information about the available services on the device."""
|
||||||
snapshot = False
|
snapshot = False
|
||||||
with suppress(*GET_CAPABILITIES_EXCEPTIONS):
|
with suppress(*GET_CAPABILITIES_EXCEPTIONS):
|
||||||
media_service = self.device.create_media_service()
|
media_service = await self.device.create_media_service()
|
||||||
media_capabilities = await media_service.GetServiceCapabilities()
|
media_capabilities = await media_service.GetServiceCapabilities()
|
||||||
snapshot = media_capabilities and media_capabilities.SnapshotUri
|
snapshot = media_capabilities and media_capabilities.SnapshotUri
|
||||||
|
|
||||||
@ -342,7 +342,7 @@ class ONVIFDevice:
|
|||||||
|
|
||||||
imaging = False
|
imaging = False
|
||||||
with suppress(*GET_CAPABILITIES_EXCEPTIONS):
|
with suppress(*GET_CAPABILITIES_EXCEPTIONS):
|
||||||
self.device.create_imaging_service()
|
await self.device.create_imaging_service()
|
||||||
imaging = True
|
imaging = True
|
||||||
|
|
||||||
return Capabilities(snapshot=snapshot, ptz=ptz, imaging=imaging)
|
return Capabilities(snapshot=snapshot, ptz=ptz, imaging=imaging)
|
||||||
@ -361,7 +361,7 @@ class ONVIFDevice:
|
|||||||
|
|
||||||
async def async_get_profiles(self) -> list[Profile]:
|
async def async_get_profiles(self) -> list[Profile]:
|
||||||
"""Obtain media profiles for this device."""
|
"""Obtain media profiles for this device."""
|
||||||
media_service = self.device.create_media_service()
|
media_service = await self.device.create_media_service()
|
||||||
LOGGER.debug("%s: xaddr for media_service: %s", self.name, media_service.xaddr)
|
LOGGER.debug("%s: xaddr for media_service: %s", self.name, media_service.xaddr)
|
||||||
try:
|
try:
|
||||||
result = await media_service.GetProfiles()
|
result = await media_service.GetProfiles()
|
||||||
@ -408,7 +408,7 @@ class ONVIFDevice:
|
|||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
ptz_service = self.device.create_ptz_service()
|
ptz_service = await self.device.create_ptz_service()
|
||||||
presets = await ptz_service.GetPresets(profile.token)
|
presets = await ptz_service.GetPresets(profile.token)
|
||||||
profile.ptz.presets = [preset.token for preset in presets if preset]
|
profile.ptz.presets = [preset.token for preset in presets if preset]
|
||||||
except GET_CAPABILITIES_EXCEPTIONS:
|
except GET_CAPABILITIES_EXCEPTIONS:
|
||||||
@ -427,7 +427,7 @@ class ONVIFDevice:
|
|||||||
|
|
||||||
async def async_get_stream_uri(self, profile: Profile) -> str:
|
async def async_get_stream_uri(self, profile: Profile) -> str:
|
||||||
"""Get the stream URI for a specified profile."""
|
"""Get the stream URI for a specified profile."""
|
||||||
media_service = self.device.create_media_service()
|
media_service = await self.device.create_media_service()
|
||||||
req = media_service.create_type("GetStreamUri")
|
req = media_service.create_type("GetStreamUri")
|
||||||
req.ProfileToken = profile.token
|
req.ProfileToken = profile.token
|
||||||
req.StreamSetup = {
|
req.StreamSetup = {
|
||||||
@ -454,7 +454,7 @@ class ONVIFDevice:
|
|||||||
LOGGER.warning("PTZ actions are not supported on device '%s'", self.name)
|
LOGGER.warning("PTZ actions are not supported on device '%s'", self.name)
|
||||||
return
|
return
|
||||||
|
|
||||||
ptz_service = self.device.create_ptz_service()
|
ptz_service = await self.device.create_ptz_service()
|
||||||
|
|
||||||
pan_val = distance * PAN_FACTOR.get(pan, 0)
|
pan_val = distance * PAN_FACTOR.get(pan, 0)
|
||||||
tilt_val = distance * TILT_FACTOR.get(tilt, 0)
|
tilt_val = distance * TILT_FACTOR.get(tilt, 0)
|
||||||
@ -576,7 +576,7 @@ class ONVIFDevice:
|
|||||||
LOGGER.warning("PTZ actions are not supported on device '%s'", self.name)
|
LOGGER.warning("PTZ actions are not supported on device '%s'", self.name)
|
||||||
return
|
return
|
||||||
|
|
||||||
ptz_service = self.device.create_ptz_service()
|
ptz_service = await self.device.create_ptz_service()
|
||||||
|
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"Running Aux Command | Cmd = %s",
|
"Running Aux Command | Cmd = %s",
|
||||||
@ -607,7 +607,7 @@ class ONVIFDevice:
|
|||||||
)
|
)
|
||||||
return
|
return
|
||||||
|
|
||||||
imaging_service = self.device.create_imaging_service()
|
imaging_service = await self.device.create_imaging_service()
|
||||||
|
|
||||||
LOGGER.debug("Setting Imaging Setting | Settings = %s", settings)
|
LOGGER.debug("Setting Imaging Setting | Settings = %s", settings)
|
||||||
try:
|
try:
|
||||||
|
@ -9,7 +9,7 @@ import datetime as dt
|
|||||||
from aiohttp.web import Request
|
from aiohttp.web import Request
|
||||||
from httpx import RemoteProtocolError, RequestError, TransportError
|
from httpx import RemoteProtocolError, RequestError, TransportError
|
||||||
from onvif import ONVIFCamera, ONVIFService
|
from onvif import ONVIFCamera, ONVIFService
|
||||||
from onvif.client import NotificationManager
|
from onvif.client import NotificationManager, retry_connection_error
|
||||||
from onvif.exceptions import ONVIFError
|
from onvif.exceptions import ONVIFError
|
||||||
from zeep.exceptions import Fault, ValidationError, XMLParseError
|
from zeep.exceptions import Fault, ValidationError, XMLParseError
|
||||||
|
|
||||||
@ -40,8 +40,8 @@ SET_SYNCHRONIZATION_POINT_ERRORS = (*SUBSCRIPTION_ERRORS, TypeError)
|
|||||||
UNSUBSCRIBE_ERRORS = (XMLParseError, *SUBSCRIPTION_ERRORS)
|
UNSUBSCRIBE_ERRORS = (XMLParseError, *SUBSCRIPTION_ERRORS)
|
||||||
RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS)
|
RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS)
|
||||||
#
|
#
|
||||||
# We only keep the subscription alive for 3 minutes, and will keep
|
# We only keep the subscription alive for 10 minutes, and will keep
|
||||||
# renewing it every 1.5 minutes. This is to avoid the camera
|
# renewing it every 8 minutes. This is to avoid the camera
|
||||||
# accumulating subscriptions which will be impossible to clean up
|
# accumulating subscriptions which will be impossible to clean up
|
||||||
# since ONVIF does not provide a way to list existing subscriptions.
|
# since ONVIF does not provide a way to list existing subscriptions.
|
||||||
#
|
#
|
||||||
@ -49,12 +49,25 @@ RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS)
|
|||||||
# sending events to us, and we will not be able to recover until
|
# sending events to us, and we will not be able to recover until
|
||||||
# the subscriptions expire or the camera is rebooted.
|
# the subscriptions expire or the camera is rebooted.
|
||||||
#
|
#
|
||||||
SUBSCRIPTION_TIME = dt.timedelta(minutes=3)
|
SUBSCRIPTION_TIME = dt.timedelta(minutes=10)
|
||||||
SUBSCRIPTION_RELATIVE_TIME = (
|
|
||||||
"PT3M" # use relative time since the time on the camera is not reliable
|
# SUBSCRIPTION_RELATIVE_TIME uses a relative time since the time on the camera
|
||||||
)
|
# is not reliable. We use 600 seconds (10 minutes) since some cameras cannot
|
||||||
SUBSCRIPTION_RENEW_INTERVAL = SUBSCRIPTION_TIME.total_seconds() / 2
|
# parse time in the format "PT10M" (10 minutes).
|
||||||
SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR = 60.0
|
SUBSCRIPTION_RELATIVE_TIME = "PT600S"
|
||||||
|
|
||||||
|
# SUBSCRIPTION_RENEW_INTERVAL Must be less than the
|
||||||
|
# overall timeout of 90 * (SUBSCRIPTION_ATTEMPTS) 2 = 180 seconds
|
||||||
|
#
|
||||||
|
# We use 8 minutes between renewals to make sure we never hit the
|
||||||
|
# 10 minute limit even if the first renewal attempt fails
|
||||||
|
SUBSCRIPTION_RENEW_INTERVAL = 8 * 60
|
||||||
|
|
||||||
|
# The number of attempts to make when creating or renewing a subscription
|
||||||
|
SUBSCRIPTION_ATTEMPTS = 2
|
||||||
|
|
||||||
|
# The time to wait before trying to restart the subscription if it fails
|
||||||
|
SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR = 60
|
||||||
|
|
||||||
PULLPOINT_POLL_TIME = dt.timedelta(seconds=60)
|
PULLPOINT_POLL_TIME = dt.timedelta(seconds=60)
|
||||||
PULLPOINT_MESSAGE_LIMIT = 100
|
PULLPOINT_MESSAGE_LIMIT = 100
|
||||||
@ -276,7 +289,13 @@ class PullPointManager:
|
|||||||
"""Pause pullpoint subscription."""
|
"""Pause pullpoint subscription."""
|
||||||
LOGGER.debug("%s: Pausing PullPoint manager", self._name)
|
LOGGER.debug("%s: Pausing PullPoint manager", self._name)
|
||||||
self.state = PullPointManagerState.PAUSED
|
self.state = PullPointManagerState.PAUSED
|
||||||
self._hass.async_create_task(self._async_cancel_and_unsubscribe())
|
# Cancel the renew job so we don't renew the subscription
|
||||||
|
# and stop pulling messages.
|
||||||
|
self._async_cancel_pullpoint_renew()
|
||||||
|
self.async_cancel_pull_messages()
|
||||||
|
# We do not unsubscribe from the pullpoint subscription and instead
|
||||||
|
# let the subscription expire since some cameras will terminate all
|
||||||
|
# subscriptions if we unsubscribe which will break the webhook.
|
||||||
|
|
||||||
@callback
|
@callback
|
||||||
def async_resume(self) -> None:
|
def async_resume(self) -> None:
|
||||||
@ -327,20 +346,7 @@ class PullPointManager:
|
|||||||
async def _async_start_pullpoint(self) -> bool:
|
async def _async_start_pullpoint(self) -> bool:
|
||||||
"""Start pullpoint subscription."""
|
"""Start pullpoint subscription."""
|
||||||
try:
|
try:
|
||||||
try:
|
started = await self._async_create_pullpoint_subscription()
|
||||||
started = await self._async_create_pullpoint_subscription()
|
|
||||||
except RequestError:
|
|
||||||
#
|
|
||||||
# We should only need to retry on RemoteProtocolError but some cameras
|
|
||||||
# are flaky and sometimes do not respond to the Renew request so we
|
|
||||||
# retry on RequestError as well.
|
|
||||||
#
|
|
||||||
# For RemoteProtocolError:
|
|
||||||
# http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server
|
|
||||||
# to close the connection at any time, we treat this as a normal and try again
|
|
||||||
# once since we do not want to declare the camera as not supporting PullPoint
|
|
||||||
# if it just happened to close the connection at the wrong time.
|
|
||||||
started = await self._async_create_pullpoint_subscription()
|
|
||||||
except CREATE_ERRORS as err:
|
except CREATE_ERRORS as err:
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
"%s: Device does not support PullPoint service or has too many subscriptions: %s",
|
"%s: Device does not support PullPoint service or has too many subscriptions: %s",
|
||||||
@ -372,16 +378,16 @@ class PullPointManager:
|
|||||||
# scheduled when the current one is done if needed.
|
# scheduled when the current one is done if needed.
|
||||||
return
|
return
|
||||||
async with self._renew_lock:
|
async with self._renew_lock:
|
||||||
next_attempt = SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR
|
next_attempt = SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR
|
||||||
try:
|
try:
|
||||||
if (
|
if await self._async_renew_pullpoint():
|
||||||
await self._async_renew_pullpoint()
|
|
||||||
or await self._async_restart_pullpoint()
|
|
||||||
):
|
|
||||||
next_attempt = SUBSCRIPTION_RENEW_INTERVAL
|
next_attempt = SUBSCRIPTION_RENEW_INTERVAL
|
||||||
|
else:
|
||||||
|
await self._async_restart_pullpoint()
|
||||||
finally:
|
finally:
|
||||||
self.async_schedule_pullpoint_renew(next_attempt)
|
self.async_schedule_pullpoint_renew(next_attempt)
|
||||||
|
|
||||||
|
@retry_connection_error(SUBSCRIPTION_ATTEMPTS)
|
||||||
async def _async_create_pullpoint_subscription(self) -> bool:
|
async def _async_create_pullpoint_subscription(self) -> bool:
|
||||||
"""Create pullpoint subscription."""
|
"""Create pullpoint subscription."""
|
||||||
|
|
||||||
@ -392,12 +398,12 @@ class PullPointManager:
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
# Create subscription manager
|
# Create subscription manager
|
||||||
self._pullpoint_subscription = self._device.create_subscription_service(
|
self._pullpoint_subscription = await self._device.create_subscription_service(
|
||||||
"PullPointSubscription"
|
"PullPointSubscription"
|
||||||
)
|
)
|
||||||
|
|
||||||
# Create the service that will be used to pull messages from the device.
|
# Create the service that will be used to pull messages from the device.
|
||||||
self._pullpoint_service = self._device.create_pullpoint_service()
|
self._pullpoint_service = await self._device.create_pullpoint_service()
|
||||||
|
|
||||||
# Initialize events
|
# Initialize events
|
||||||
with suppress(*SET_SYNCHRONIZATION_POINT_ERRORS):
|
with suppress(*SET_SYNCHRONIZATION_POINT_ERRORS):
|
||||||
@ -447,6 +453,11 @@ class PullPointManager:
|
|||||||
)
|
)
|
||||||
self._pullpoint_subscription = None
|
self._pullpoint_subscription = None
|
||||||
|
|
||||||
|
@retry_connection_error(SUBSCRIPTION_ATTEMPTS)
|
||||||
|
async def _async_call_pullpoint_subscription_renew(self) -> None:
|
||||||
|
"""Call PullPoint subscription Renew."""
|
||||||
|
await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME)
|
||||||
|
|
||||||
async def _async_renew_pullpoint(self) -> bool:
|
async def _async_renew_pullpoint(self) -> bool:
|
||||||
"""Renew the PullPoint subscription."""
|
"""Renew the PullPoint subscription."""
|
||||||
if (
|
if (
|
||||||
@ -458,20 +469,7 @@ class PullPointManager:
|
|||||||
# The first time we renew, we may get a Fault error so we
|
# The first time we renew, we may get a Fault error so we
|
||||||
# suppress it. The subscription will be restarted in
|
# suppress it. The subscription will be restarted in
|
||||||
# async_restart later.
|
# async_restart later.
|
||||||
try:
|
await self._async_call_pullpoint_subscription_renew()
|
||||||
await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME)
|
|
||||||
except RequestError:
|
|
||||||
#
|
|
||||||
# We should only need to retry on RemoteProtocolError but some cameras
|
|
||||||
# are flaky and sometimes do not respond to the Renew request so we
|
|
||||||
# retry on RequestError as well.
|
|
||||||
#
|
|
||||||
# For RemoteProtocolError:
|
|
||||||
# http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server
|
|
||||||
# to close the connection at any time, we treat this as a normal and try again
|
|
||||||
# once since we do not want to mark events as stale
|
|
||||||
# if it just happened to close the connection at the wrong time.
|
|
||||||
await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME)
|
|
||||||
LOGGER.debug("%s: Renewed PullPoint subscription", self._name)
|
LOGGER.debug("%s: Renewed PullPoint subscription", self._name)
|
||||||
return True
|
return True
|
||||||
except RENEW_ERRORS as err:
|
except RENEW_ERRORS as err:
|
||||||
@ -521,7 +519,7 @@ class PullPointManager:
|
|||||||
stringify_onvif_error(err),
|
stringify_onvif_error(err),
|
||||||
)
|
)
|
||||||
return True
|
return True
|
||||||
except (XMLParseError, *SUBSCRIPTION_ERRORS) as err:
|
except Fault as err:
|
||||||
# Device may not support subscriptions so log at debug level
|
# Device may not support subscriptions so log at debug level
|
||||||
# when we get an XMLParseError
|
# when we get an XMLParseError
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
@ -532,6 +530,16 @@ class PullPointManager:
|
|||||||
# Treat errors as if the camera restarted. Assume that the pullpoint
|
# Treat errors as if the camera restarted. Assume that the pullpoint
|
||||||
# subscription is no longer valid.
|
# subscription is no longer valid.
|
||||||
return False
|
return False
|
||||||
|
except (XMLParseError, RequestError, TimeoutError, TransportError) as err:
|
||||||
|
LOGGER.debug(
|
||||||
|
"%s: PullPoint subscription encountered an unexpected error and will be retried "
|
||||||
|
"(this is normal for some cameras): %s",
|
||||||
|
self._name,
|
||||||
|
stringify_onvif_error(err),
|
||||||
|
)
|
||||||
|
# Avoid renewing the subscription too often since it causes problems
|
||||||
|
# for some cameras, mainly the Tapo ones.
|
||||||
|
return True
|
||||||
|
|
||||||
if self.state != PullPointManagerState.STARTED:
|
if self.state != PullPointManagerState.STARTED:
|
||||||
# If the webhook became started working during the long poll,
|
# If the webhook became started working during the long poll,
|
||||||
@ -655,6 +663,7 @@ class WebHookManager:
|
|||||||
self._renew_or_restart_job,
|
self._renew_or_restart_job,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@retry_connection_error(SUBSCRIPTION_ATTEMPTS)
|
||||||
async def _async_create_webhook_subscription(self) -> None:
|
async def _async_create_webhook_subscription(self) -> None:
|
||||||
"""Create webhook subscription."""
|
"""Create webhook subscription."""
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
@ -689,20 +698,7 @@ class WebHookManager:
|
|||||||
async def _async_start_webhook(self) -> bool:
|
async def _async_start_webhook(self) -> bool:
|
||||||
"""Start webhook."""
|
"""Start webhook."""
|
||||||
try:
|
try:
|
||||||
try:
|
await self._async_create_webhook_subscription()
|
||||||
await self._async_create_webhook_subscription()
|
|
||||||
except RequestError:
|
|
||||||
#
|
|
||||||
# We should only need to retry on RemoteProtocolError but some cameras
|
|
||||||
# are flaky and sometimes do not respond to the Renew request so we
|
|
||||||
# retry on RequestError as well.
|
|
||||||
#
|
|
||||||
# For RemoteProtocolError:
|
|
||||||
# http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server
|
|
||||||
# to close the connection at any time, we treat this as a normal and try again
|
|
||||||
# once since we do not want to declare the camera as not supporting webhooks
|
|
||||||
# if it just happened to close the connection at the wrong time.
|
|
||||||
await self._async_create_webhook_subscription()
|
|
||||||
except CREATE_ERRORS as err:
|
except CREATE_ERRORS as err:
|
||||||
self._event_manager.async_webhook_failed()
|
self._event_manager.async_webhook_failed()
|
||||||
LOGGER.debug(
|
LOGGER.debug(
|
||||||
@ -720,6 +716,12 @@ class WebHookManager:
|
|||||||
await self._async_unsubscribe_webhook()
|
await self._async_unsubscribe_webhook()
|
||||||
return await self._async_start_webhook()
|
return await self._async_start_webhook()
|
||||||
|
|
||||||
|
@retry_connection_error(SUBSCRIPTION_ATTEMPTS)
|
||||||
|
async def _async_call_webhook_subscription_renew(self) -> None:
|
||||||
|
"""Call PullPoint subscription Renew."""
|
||||||
|
assert self._webhook_subscription is not None
|
||||||
|
await self._webhook_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME)
|
||||||
|
|
||||||
async def _async_renew_webhook(self) -> bool:
|
async def _async_renew_webhook(self) -> bool:
|
||||||
"""Renew webhook subscription."""
|
"""Renew webhook subscription."""
|
||||||
if (
|
if (
|
||||||
@ -728,20 +730,7 @@ class WebHookManager:
|
|||||||
):
|
):
|
||||||
return False
|
return False
|
||||||
try:
|
try:
|
||||||
try:
|
await self._async_call_webhook_subscription_renew()
|
||||||
await self._webhook_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME)
|
|
||||||
except RequestError:
|
|
||||||
#
|
|
||||||
# We should only need to retry on RemoteProtocolError but some cameras
|
|
||||||
# are flaky and sometimes do not respond to the Renew request so we
|
|
||||||
# retry on RequestError as well.
|
|
||||||
#
|
|
||||||
# For RemoteProtocolError:
|
|
||||||
# http://datatracker.ietf.org/doc/html/rfc2616#section-8.1.4 allows the server
|
|
||||||
# to close the connection at any time, we treat this as a normal and try again
|
|
||||||
# once since we do not want to mark events as stale
|
|
||||||
# if it just happened to close the connection at the wrong time.
|
|
||||||
await self._webhook_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME)
|
|
||||||
LOGGER.debug("%s: Renewed Webhook subscription", self._name)
|
LOGGER.debug("%s: Renewed Webhook subscription", self._name)
|
||||||
return True
|
return True
|
||||||
except RENEW_ERRORS as err:
|
except RENEW_ERRORS as err:
|
||||||
@ -765,13 +754,12 @@ class WebHookManager:
|
|||||||
# scheduled when the current one is done if needed.
|
# scheduled when the current one is done if needed.
|
||||||
return
|
return
|
||||||
async with self._renew_lock:
|
async with self._renew_lock:
|
||||||
next_attempt = SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR
|
next_attempt = SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR
|
||||||
try:
|
try:
|
||||||
if (
|
if await self._async_renew_webhook():
|
||||||
await self._async_renew_webhook()
|
|
||||||
or await self._async_restart_webhook()
|
|
||||||
):
|
|
||||||
next_attempt = SUBSCRIPTION_RENEW_INTERVAL
|
next_attempt = SUBSCRIPTION_RENEW_INTERVAL
|
||||||
|
else:
|
||||||
|
await self._async_restart_webhook()
|
||||||
finally:
|
finally:
|
||||||
self._async_schedule_webhook_renew(next_attempt)
|
self._async_schedule_webhook_renew(next_attempt)
|
||||||
|
|
||||||
|
@ -8,5 +8,5 @@
|
|||||||
"documentation": "https://www.home-assistant.io/integrations/onvif",
|
"documentation": "https://www.home-assistant.io/integrations/onvif",
|
||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["onvif", "wsdiscovery", "zeep"],
|
"loggers": ["onvif", "wsdiscovery", "zeep"],
|
||||||
"requirements": ["onvif-zeep-async==1.3.1", "WSDiscovery==2.0.0"]
|
"requirements": ["onvif-zeep-async==2.1.1", "WSDiscovery==2.0.0"]
|
||||||
}
|
}
|
||||||
|
@ -15,6 +15,19 @@ PARSERS: Registry[
|
|||||||
str, Callable[[str, Any], Coroutine[Any, Any, Event | None]]
|
str, Callable[[str, Any], Coroutine[Any, Any, Event | None]]
|
||||||
] = Registry()
|
] = Registry()
|
||||||
|
|
||||||
|
VIDEO_SOURCE_MAPPING = {
|
||||||
|
"vsconf": "VideoSourceToken",
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def _normalize_video_source(source: str) -> str:
|
||||||
|
"""Normalize video source.
|
||||||
|
|
||||||
|
Some cameras do not set the VideoSourceToken correctly so we get duplicate
|
||||||
|
sensors, so we need to normalize it to the correct value.
|
||||||
|
"""
|
||||||
|
return VIDEO_SOURCE_MAPPING.get(source, source)
|
||||||
|
|
||||||
|
|
||||||
def local_datetime_or_none(value: str) -> datetime.datetime | None:
|
def local_datetime_or_none(value: str) -> datetime.datetime | None:
|
||||||
"""Convert strings to datetimes, if invalid, return None."""
|
"""Convert strings to datetimes, if invalid, return None."""
|
||||||
@ -188,7 +201,7 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None:
|
|||||||
rule = ""
|
rule = ""
|
||||||
for source in msg.Message._value_1.Source.SimpleItem:
|
for source in msg.Message._value_1.Source.SimpleItem:
|
||||||
if source.Name == "VideoSourceConfigurationToken":
|
if source.Name == "VideoSourceConfigurationToken":
|
||||||
video_source = source.Value
|
video_source = _normalize_video_source(source.Value)
|
||||||
if source.Name == "VideoAnalyticsConfigurationToken":
|
if source.Name == "VideoAnalyticsConfigurationToken":
|
||||||
video_analytics = source.Value
|
video_analytics = source.Value
|
||||||
if source.Name == "Rule":
|
if source.Name == "Rule":
|
||||||
@ -220,7 +233,7 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None:
|
|||||||
rule = ""
|
rule = ""
|
||||||
for source in msg.Message._value_1.Source.SimpleItem:
|
for source in msg.Message._value_1.Source.SimpleItem:
|
||||||
if source.Name == "VideoSourceConfigurationToken":
|
if source.Name == "VideoSourceConfigurationToken":
|
||||||
video_source = source.Value
|
video_source = _normalize_video_source(source.Value)
|
||||||
if source.Name == "VideoAnalyticsConfigurationToken":
|
if source.Name == "VideoAnalyticsConfigurationToken":
|
||||||
video_analytics = source.Value
|
video_analytics = source.Value
|
||||||
if source.Name == "Rule":
|
if source.Name == "Rule":
|
||||||
@ -251,7 +264,7 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None:
|
|||||||
rule = ""
|
rule = ""
|
||||||
for source in msg.Message._value_1.Source.SimpleItem:
|
for source in msg.Message._value_1.Source.SimpleItem:
|
||||||
if source.Name == "VideoSourceConfigurationToken":
|
if source.Name == "VideoSourceConfigurationToken":
|
||||||
video_source = source.Value
|
video_source = _normalize_video_source(source.Value)
|
||||||
if source.Name == "VideoAnalyticsConfigurationToken":
|
if source.Name == "VideoAnalyticsConfigurationToken":
|
||||||
video_analytics = source.Value
|
video_analytics = source.Value
|
||||||
if source.Name == "Rule":
|
if source.Name == "Rule":
|
||||||
@ -282,7 +295,7 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None:
|
|||||||
rule = ""
|
rule = ""
|
||||||
for source in msg.Message._value_1.Source.SimpleItem:
|
for source in msg.Message._value_1.Source.SimpleItem:
|
||||||
if source.Name == "VideoSourceConfigurationToken":
|
if source.Name == "VideoSourceConfigurationToken":
|
||||||
video_source = source.Value
|
video_source = _normalize_video_source(source.Value)
|
||||||
if source.Name == "VideoAnalyticsConfigurationToken":
|
if source.Name == "VideoAnalyticsConfigurationToken":
|
||||||
video_analytics = source.Value
|
video_analytics = source.Value
|
||||||
if source.Name == "Rule":
|
if source.Name == "Rule":
|
||||||
@ -312,7 +325,7 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None:
|
|||||||
video_source = ""
|
video_source = ""
|
||||||
for source in msg.Message._value_1.Source.SimpleItem:
|
for source in msg.Message._value_1.Source.SimpleItem:
|
||||||
if source.Name == "Source":
|
if source.Name == "Source":
|
||||||
video_source = source.Value
|
video_source = _normalize_video_source(source.Value)
|
||||||
|
|
||||||
return Event(
|
return Event(
|
||||||
f"{uid}_{msg.Topic._value_1}_{video_source}",
|
f"{uid}_{msg.Topic._value_1}_{video_source}",
|
||||||
@ -337,7 +350,7 @@ async def async_parse_vehicle_detector(uid: str, msg) -> Event | None:
|
|||||||
video_source = ""
|
video_source = ""
|
||||||
for source in msg.Message._value_1.Source.SimpleItem:
|
for source in msg.Message._value_1.Source.SimpleItem:
|
||||||
if source.Name == "Source":
|
if source.Name == "Source":
|
||||||
video_source = source.Value
|
video_source = _normalize_video_source(source.Value)
|
||||||
|
|
||||||
return Event(
|
return Event(
|
||||||
f"{uid}_{msg.Topic._value_1}_{video_source}",
|
f"{uid}_{msg.Topic._value_1}_{video_source}",
|
||||||
@ -362,7 +375,7 @@ async def async_parse_person_detector(uid: str, msg) -> Event | None:
|
|||||||
video_source = ""
|
video_source = ""
|
||||||
for source in msg.Message._value_1.Source.SimpleItem:
|
for source in msg.Message._value_1.Source.SimpleItem:
|
||||||
if source.Name == "Source":
|
if source.Name == "Source":
|
||||||
video_source = source.Value
|
video_source = _normalize_video_source(source.Value)
|
||||||
|
|
||||||
return Event(
|
return Event(
|
||||||
f"{uid}_{msg.Topic._value_1}_{video_source}",
|
f"{uid}_{msg.Topic._value_1}_{video_source}",
|
||||||
@ -387,7 +400,7 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None:
|
|||||||
video_source = ""
|
video_source = ""
|
||||||
for source in msg.Message._value_1.Source.SimpleItem:
|
for source in msg.Message._value_1.Source.SimpleItem:
|
||||||
if source.Name == "Source":
|
if source.Name == "Source":
|
||||||
video_source = source.Value
|
video_source = _normalize_video_source(source.Value)
|
||||||
|
|
||||||
return Event(
|
return Event(
|
||||||
f"{uid}_{msg.Topic._value_1}_{video_source}",
|
f"{uid}_{msg.Topic._value_1}_{video_source}",
|
||||||
@ -401,6 +414,31 @@ async def async_parse_face_detector(uid: str, msg) -> Event | None:
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
@PARSERS.register("tns1:RuleEngine/MyRuleDetector/Visitor")
|
||||||
|
# pylint: disable=protected-access
|
||||||
|
async def async_parse_visitor_detector(uid: str, msg) -> Event | None:
|
||||||
|
"""Handle parsing event message.
|
||||||
|
|
||||||
|
Topic: tns1:RuleEngine/MyRuleDetector/Visitor
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
video_source = ""
|
||||||
|
for source in msg.Message._value_1.Source.SimpleItem:
|
||||||
|
if source.Name == "Source":
|
||||||
|
video_source = _normalize_video_source(source.Value)
|
||||||
|
|
||||||
|
return Event(
|
||||||
|
f"{uid}_{msg.Topic._value_1}_{video_source}",
|
||||||
|
"Visitor Detection",
|
||||||
|
"binary_sensor",
|
||||||
|
"occupancy",
|
||||||
|
None,
|
||||||
|
msg.Message._value_1.Data.SimpleItem[0].Value == "true",
|
||||||
|
)
|
||||||
|
except (AttributeError, KeyError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
@PARSERS.register("tns1:Device/Trigger/DigitalInput")
|
@PARSERS.register("tns1:Device/Trigger/DigitalInput")
|
||||||
# pylint: disable=protected-access
|
# pylint: disable=protected-access
|
||||||
async def async_parse_digital_input(uid: str, msg) -> Event | None:
|
async def async_parse_digital_input(uid: str, msg) -> Event | None:
|
||||||
@ -658,7 +696,7 @@ async def async_parse_count_aggregation_counter(uid: str, msg) -> Event | None:
|
|||||||
rule = ""
|
rule = ""
|
||||||
for source in msg.Message._value_1.Source.SimpleItem:
|
for source in msg.Message._value_1.Source.SimpleItem:
|
||||||
if source.Name == "VideoSourceConfigurationToken":
|
if source.Name == "VideoSourceConfigurationToken":
|
||||||
video_source = source.Value
|
video_source = _normalize_video_source(source.Value)
|
||||||
if source.Name == "VideoAnalyticsConfigurationToken":
|
if source.Name == "VideoAnalyticsConfigurationToken":
|
||||||
video_analytics = source.Value
|
video_analytics = source.Value
|
||||||
if source.Name == "Rule":
|
if source.Name == "Rule":
|
||||||
|
@ -34,7 +34,7 @@ def stringify_onvif_error(error: Exception) -> str:
|
|||||||
message += f" (actor:{error.actor})"
|
message += f" (actor:{error.actor})"
|
||||||
else:
|
else:
|
||||||
message = str(error)
|
message = str(error)
|
||||||
return message or "Device sent empty error"
|
return message or f"Device sent empty error with type {type(error)}"
|
||||||
|
|
||||||
|
|
||||||
def is_auth_error(error: Exception) -> bool:
|
def is_auth_error(error: Exception) -> bool:
|
||||||
|
@ -38,7 +38,8 @@ DEFAULT_ALTITUDE = 0
|
|||||||
|
|
||||||
EVENT_OPENSKY_ENTRY = f"{DOMAIN}_entry"
|
EVENT_OPENSKY_ENTRY = f"{DOMAIN}_entry"
|
||||||
EVENT_OPENSKY_EXIT = f"{DOMAIN}_exit"
|
EVENT_OPENSKY_EXIT = f"{DOMAIN}_exit"
|
||||||
SCAN_INTERVAL = timedelta(seconds=12) # opensky public limit is 10 seconds
|
# OpenSky free user has 400 credits, with 4 credits per API call. 100/24 = ~4 requests per hour
|
||||||
|
SCAN_INTERVAL = timedelta(minutes=15)
|
||||||
|
|
||||||
OPENSKY_API_URL = "https://opensky-network.org/api/states/all"
|
OPENSKY_API_URL = "https://opensky-network.org/api/states/all"
|
||||||
OPENSKY_API_FIELDS = [
|
OPENSKY_API_FIELDS = [
|
||||||
|
@ -29,7 +29,7 @@ apply:
|
|||||||
name: Entities state
|
name: Entities state
|
||||||
description: The entities and the state that they need to be.
|
description: The entities and the state that they need to be.
|
||||||
required: true
|
required: true
|
||||||
example:
|
example: |
|
||||||
light.kitchen: "on"
|
light.kitchen: "on"
|
||||||
light.ceiling:
|
light.ceiling:
|
||||||
state: "on"
|
state: "on"
|
||||||
@ -60,7 +60,7 @@ create:
|
|||||||
entities:
|
entities:
|
||||||
name: Entities state
|
name: Entities state
|
||||||
description: The entities to control with the scene.
|
description: The entities to control with the scene.
|
||||||
example:
|
example: |
|
||||||
light.tv_back_light: "on"
|
light.tv_back_light: "on"
|
||||||
light.ceiling:
|
light.ceiling:
|
||||||
state: "on"
|
state: "on"
|
||||||
@ -70,7 +70,7 @@ create:
|
|||||||
snapshot_entities:
|
snapshot_entities:
|
||||||
name: Snapshot entities
|
name: Snapshot entities
|
||||||
description: The entities of which a snapshot is to be taken
|
description: The entities of which a snapshot is to be taken
|
||||||
example:
|
example: |
|
||||||
- light.ceiling
|
- light.ceiling
|
||||||
- light.kitchen
|
- light.kitchen
|
||||||
selector:
|
selector:
|
||||||
|
@ -123,7 +123,7 @@ class SIAAlarmControlPanel(SIABaseEntity, AlarmControlPanelEntity):
|
|||||||
"""
|
"""
|
||||||
new_state = None
|
new_state = None
|
||||||
if sia_event.code:
|
if sia_event.code:
|
||||||
new_state = self.entity_description.code_consequences[sia_event.code]
|
new_state = self.entity_description.code_consequences.get(sia_event.code)
|
||||||
if new_state is None:
|
if new_state is None:
|
||||||
return False
|
return False
|
||||||
_LOGGER.debug("New state will be %s", new_state)
|
_LOGGER.debug("New state will be %s", new_state)
|
||||||
|
@ -132,7 +132,7 @@ class SIABinarySensor(SIABaseEntity, BinarySensorEntity):
|
|||||||
"""
|
"""
|
||||||
new_state = None
|
new_state = None
|
||||||
if sia_event.code:
|
if sia_event.code:
|
||||||
new_state = self.entity_description.code_consequences[sia_event.code]
|
new_state = self.entity_description.code_consequences.get(sia_event.code)
|
||||||
if new_state is None:
|
if new_state is None:
|
||||||
return False
|
return False
|
||||||
_LOGGER.debug("New state will be %s", new_state)
|
_LOGGER.debug("New state will be %s", new_state)
|
||||||
|
@ -8,5 +8,5 @@
|
|||||||
"iot_class": "local_push",
|
"iot_class": "local_push",
|
||||||
"loggers": ["hatasmota"],
|
"loggers": ["hatasmota"],
|
||||||
"mqtt": ["tasmota/discovery/#"],
|
"mqtt": ["tasmota/discovery/#"],
|
||||||
"requirements": ["hatasmota==0.6.4"]
|
"requirements": ["hatasmota==0.6.5"]
|
||||||
}
|
}
|
||||||
|
@ -7,7 +7,11 @@ import logging
|
|||||||
from typing import Any
|
from typing import Any
|
||||||
|
|
||||||
import transmission_rpc
|
import transmission_rpc
|
||||||
from transmission_rpc.error import TransmissionError
|
from transmission_rpc.error import (
|
||||||
|
TransmissionAuthError,
|
||||||
|
TransmissionConnectError,
|
||||||
|
TransmissionError,
|
||||||
|
)
|
||||||
import voluptuous as vol
|
import voluptuous as vol
|
||||||
|
|
||||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||||
@ -137,14 +141,13 @@ async def get_api(hass, entry):
|
|||||||
_LOGGER.debug("Successfully connected to %s", host)
|
_LOGGER.debug("Successfully connected to %s", host)
|
||||||
return api
|
return api
|
||||||
|
|
||||||
|
except TransmissionAuthError as error:
|
||||||
|
_LOGGER.error("Credentials for Transmission client are not valid")
|
||||||
|
raise AuthenticationError from error
|
||||||
|
except TransmissionConnectError as error:
|
||||||
|
_LOGGER.error("Connecting to the Transmission client %s failed", host)
|
||||||
|
raise CannotConnect from error
|
||||||
except TransmissionError as error:
|
except TransmissionError as error:
|
||||||
if "401: Unauthorized" in str(error):
|
|
||||||
_LOGGER.error("Credentials for Transmission client are not valid")
|
|
||||||
raise AuthenticationError from error
|
|
||||||
if "111: Connection refused" in str(error):
|
|
||||||
_LOGGER.error("Connecting to the Transmission client %s failed", host)
|
|
||||||
raise CannotConnect from error
|
|
||||||
|
|
||||||
_LOGGER.error(error)
|
_LOGGER.error(error)
|
||||||
raise UnknownError from error
|
raise UnknownError from error
|
||||||
|
|
||||||
|
@ -137,7 +137,19 @@ class Endpoint:
|
|||||||
):
|
):
|
||||||
cluster_handler_class = MultistateInput
|
cluster_handler_class = MultistateInput
|
||||||
# end of ugly hack
|
# end of ugly hack
|
||||||
cluster_handler = cluster_handler_class(cluster, self)
|
|
||||||
|
try:
|
||||||
|
cluster_handler = cluster_handler_class(cluster, self)
|
||||||
|
except KeyError as err:
|
||||||
|
_LOGGER.warning(
|
||||||
|
"Cluster handler %s for cluster %s on endpoint %s is invalid: %s",
|
||||||
|
cluster_handler_class,
|
||||||
|
cluster,
|
||||||
|
self,
|
||||||
|
err,
|
||||||
|
)
|
||||||
|
continue
|
||||||
|
|
||||||
if cluster_handler.name == const.CLUSTER_HANDLER_POWER_CONFIGURATION:
|
if cluster_handler.name == const.CLUSTER_HANDLER_POWER_CONFIGURATION:
|
||||||
self._device.power_configuration_ch = cluster_handler
|
self._device.power_configuration_ch = cluster_handler
|
||||||
elif cluster_handler.name == const.CLUSTER_HANDLER_IDENTIFY:
|
elif cluster_handler.name == const.CLUSTER_HANDLER_IDENTIFY:
|
||||||
|
@ -8,7 +8,7 @@ from .backports.enum import StrEnum
|
|||||||
APPLICATION_NAME: Final = "HomeAssistant"
|
APPLICATION_NAME: Final = "HomeAssistant"
|
||||||
MAJOR_VERSION: Final = 2023
|
MAJOR_VERSION: Final = 2023
|
||||||
MINOR_VERSION: Final = 5
|
MINOR_VERSION: Final = 5
|
||||||
PATCH_VERSION: Final = "1"
|
PATCH_VERSION: Final = "2"
|
||||||
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
|
||||||
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
|
||||||
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0)
|
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 10, 0)
|
||||||
|
@ -25,7 +25,7 @@ ha-av==10.0.0
|
|||||||
hass-nabucasa==0.66.2
|
hass-nabucasa==0.66.2
|
||||||
hassil==1.0.6
|
hassil==1.0.6
|
||||||
home-assistant-bluetooth==1.10.0
|
home-assistant-bluetooth==1.10.0
|
||||||
home-assistant-frontend==20230503.2
|
home-assistant-frontend==20230503.3
|
||||||
home-assistant-intents==2023.4.26
|
home-assistant-intents==2023.4.26
|
||||||
httpx==0.24.0
|
httpx==0.24.0
|
||||||
ifaddr==0.1.7
|
ifaddr==0.1.7
|
||||||
|
@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "homeassistant"
|
name = "homeassistant"
|
||||||
version = "2023.5.1"
|
version = "2023.5.2"
|
||||||
license = {text = "Apache-2.0"}
|
license = {text = "Apache-2.0"}
|
||||||
description = "Open-source home automation platform running on Python 3."
|
description = "Open-source home automation platform running on Python 3."
|
||||||
readme = "README.rst"
|
readme = "README.rst"
|
||||||
|
@ -156,7 +156,7 @@ aioecowitt==2023.01.0
|
|||||||
aioemonitor==1.0.5
|
aioemonitor==1.0.5
|
||||||
|
|
||||||
# homeassistant.components.esphome
|
# homeassistant.components.esphome
|
||||||
aioesphomeapi==13.7.2
|
aioesphomeapi==13.7.3
|
||||||
|
|
||||||
# homeassistant.components.flo
|
# homeassistant.components.flo
|
||||||
aioflo==2021.11.0
|
aioflo==2021.11.0
|
||||||
@ -644,7 +644,7 @@ elgato==4.0.1
|
|||||||
eliqonline==1.2.2
|
eliqonline==1.2.2
|
||||||
|
|
||||||
# homeassistant.components.elkm1
|
# homeassistant.components.elkm1
|
||||||
elkm1-lib==2.2.1
|
elkm1-lib==2.2.2
|
||||||
|
|
||||||
# homeassistant.components.elmax
|
# homeassistant.components.elmax
|
||||||
elmax_api==0.0.4
|
elmax_api==0.0.4
|
||||||
@ -881,7 +881,7 @@ hass_splunk==0.1.1
|
|||||||
hassil==1.0.6
|
hassil==1.0.6
|
||||||
|
|
||||||
# homeassistant.components.tasmota
|
# homeassistant.components.tasmota
|
||||||
hatasmota==0.6.4
|
hatasmota==0.6.5
|
||||||
|
|
||||||
# homeassistant.components.jewish_calendar
|
# homeassistant.components.jewish_calendar
|
||||||
hdate==0.10.4
|
hdate==0.10.4
|
||||||
@ -911,7 +911,7 @@ hole==0.8.0
|
|||||||
holidays==0.21.13
|
holidays==0.21.13
|
||||||
|
|
||||||
# homeassistant.components.frontend
|
# homeassistant.components.frontend
|
||||||
home-assistant-frontend==20230503.2
|
home-assistant-frontend==20230503.3
|
||||||
|
|
||||||
# homeassistant.components.conversation
|
# homeassistant.components.conversation
|
||||||
home-assistant-intents==2023.4.26
|
home-assistant-intents==2023.4.26
|
||||||
@ -1264,7 +1264,7 @@ ondilo==0.2.0
|
|||||||
onkyo-eiscp==1.2.7
|
onkyo-eiscp==1.2.7
|
||||||
|
|
||||||
# homeassistant.components.onvif
|
# homeassistant.components.onvif
|
||||||
onvif-zeep-async==1.3.1
|
onvif-zeep-async==2.1.1
|
||||||
|
|
||||||
# homeassistant.components.opengarage
|
# homeassistant.components.opengarage
|
||||||
open-garage==0.2.0
|
open-garage==0.2.0
|
||||||
|
@ -146,7 +146,7 @@ aioecowitt==2023.01.0
|
|||||||
aioemonitor==1.0.5
|
aioemonitor==1.0.5
|
||||||
|
|
||||||
# homeassistant.components.esphome
|
# homeassistant.components.esphome
|
||||||
aioesphomeapi==13.7.2
|
aioesphomeapi==13.7.3
|
||||||
|
|
||||||
# homeassistant.components.flo
|
# homeassistant.components.flo
|
||||||
aioflo==2021.11.0
|
aioflo==2021.11.0
|
||||||
@ -506,7 +506,7 @@ easyenergy==0.3.0
|
|||||||
elgato==4.0.1
|
elgato==4.0.1
|
||||||
|
|
||||||
# homeassistant.components.elkm1
|
# homeassistant.components.elkm1
|
||||||
elkm1-lib==2.2.1
|
elkm1-lib==2.2.2
|
||||||
|
|
||||||
# homeassistant.components.elmax
|
# homeassistant.components.elmax
|
||||||
elmax_api==0.0.4
|
elmax_api==0.0.4
|
||||||
@ -679,7 +679,7 @@ hass-nabucasa==0.66.2
|
|||||||
hassil==1.0.6
|
hassil==1.0.6
|
||||||
|
|
||||||
# homeassistant.components.tasmota
|
# homeassistant.components.tasmota
|
||||||
hatasmota==0.6.4
|
hatasmota==0.6.5
|
||||||
|
|
||||||
# homeassistant.components.jewish_calendar
|
# homeassistant.components.jewish_calendar
|
||||||
hdate==0.10.4
|
hdate==0.10.4
|
||||||
@ -700,7 +700,7 @@ hole==0.8.0
|
|||||||
holidays==0.21.13
|
holidays==0.21.13
|
||||||
|
|
||||||
# homeassistant.components.frontend
|
# homeassistant.components.frontend
|
||||||
home-assistant-frontend==20230503.2
|
home-assistant-frontend==20230503.3
|
||||||
|
|
||||||
# homeassistant.components.conversation
|
# homeassistant.components.conversation
|
||||||
home-assistant-intents==2023.4.26
|
home-assistant-intents==2023.4.26
|
||||||
@ -945,7 +945,7 @@ omnilogic==0.4.5
|
|||||||
ondilo==0.2.0
|
ondilo==0.2.0
|
||||||
|
|
||||||
# homeassistant.components.onvif
|
# homeassistant.components.onvif
|
||||||
onvif-zeep-async==1.3.1
|
onvif-zeep-async==2.1.1
|
||||||
|
|
||||||
# homeassistant.components.opengarage
|
# homeassistant.components.opengarage
|
||||||
open-garage==0.2.0
|
open-garage==0.2.0
|
||||||
|
@ -542,11 +542,13 @@ async def test_alexa_handle_logout(
|
|||||||
assert len(mock_enable.return_value.mock_calls) == 1
|
assert len(mock_enable.return_value.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("alexa_settings_version", [1, 2])
|
||||||
async def test_alexa_config_migrate_expose_entity_prefs(
|
async def test_alexa_config_migrate_expose_entity_prefs(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
cloud_prefs: CloudPreferences,
|
cloud_prefs: CloudPreferences,
|
||||||
cloud_stub,
|
cloud_stub,
|
||||||
entity_registry: er.EntityRegistry,
|
entity_registry: er.EntityRegistry,
|
||||||
|
alexa_settings_version: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test migrating Alexa entity config."""
|
"""Test migrating Alexa entity config."""
|
||||||
hass.state = CoreState.starting
|
hass.state = CoreState.starting
|
||||||
@ -593,7 +595,7 @@ async def test_alexa_config_migrate_expose_entity_prefs(
|
|||||||
await cloud_prefs.async_update(
|
await cloud_prefs.async_update(
|
||||||
alexa_enabled=True,
|
alexa_enabled=True,
|
||||||
alexa_report_state=False,
|
alexa_report_state=False,
|
||||||
alexa_settings_version=1,
|
alexa_settings_version=alexa_settings_version,
|
||||||
)
|
)
|
||||||
expose_entity(hass, entity_migrated.entity_id, False)
|
expose_entity(hass, entity_migrated.entity_id, False)
|
||||||
|
|
||||||
@ -641,6 +643,100 @@ async def test_alexa_config_migrate_expose_entity_prefs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_alexa_config_migrate_expose_entity_prefs_v2_no_exposed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cloud_prefs: CloudPreferences,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
) -> None:
|
||||||
|
"""Test migrating Alexa entity config from v2 to v3 when no entity is exposed."""
|
||||||
|
hass.state = CoreState.starting
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, "homeassistant", {})
|
||||||
|
hass.states.async_set("light.state_only", "on")
|
||||||
|
entity_migrated = entity_registry.async_get_or_create(
|
||||||
|
"light",
|
||||||
|
"test",
|
||||||
|
"light_migrated",
|
||||||
|
suggested_object_id="migrated",
|
||||||
|
)
|
||||||
|
await cloud_prefs.async_update(
|
||||||
|
alexa_enabled=True,
|
||||||
|
alexa_report_state=False,
|
||||||
|
alexa_settings_version=2,
|
||||||
|
)
|
||||||
|
expose_entity(hass, "light.state_only", False)
|
||||||
|
expose_entity(hass, entity_migrated.entity_id, False)
|
||||||
|
|
||||||
|
cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.state_only"] = {
|
||||||
|
PREF_SHOULD_EXPOSE: True
|
||||||
|
}
|
||||||
|
cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_migrated.entity_id] = {
|
||||||
|
PREF_SHOULD_EXPOSE: True
|
||||||
|
}
|
||||||
|
conf = alexa_config.CloudAlexaConfig(
|
||||||
|
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False)
|
||||||
|
)
|
||||||
|
await conf.async_initialize()
|
||||||
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert async_get_entity_settings(hass, "light.state_only") == {
|
||||||
|
"cloud.alexa": {"should_expose": True}
|
||||||
|
}
|
||||||
|
assert async_get_entity_settings(hass, entity_migrated.entity_id) == {
|
||||||
|
"cloud.alexa": {"should_expose": True}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_alexa_config_migrate_expose_entity_prefs_v2_exposed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cloud_prefs: CloudPreferences,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
) -> None:
|
||||||
|
"""Test migrating Alexa entity config from v2 to v3 when an entity is exposed."""
|
||||||
|
hass.state = CoreState.starting
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, "homeassistant", {})
|
||||||
|
hass.states.async_set("light.state_only", "on")
|
||||||
|
entity_migrated = entity_registry.async_get_or_create(
|
||||||
|
"light",
|
||||||
|
"test",
|
||||||
|
"light_migrated",
|
||||||
|
suggested_object_id="migrated",
|
||||||
|
)
|
||||||
|
await cloud_prefs.async_update(
|
||||||
|
alexa_enabled=True,
|
||||||
|
alexa_report_state=False,
|
||||||
|
alexa_settings_version=2,
|
||||||
|
)
|
||||||
|
expose_entity(hass, "light.state_only", False)
|
||||||
|
expose_entity(hass, entity_migrated.entity_id, True)
|
||||||
|
|
||||||
|
cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS]["light.state_only"] = {
|
||||||
|
PREF_SHOULD_EXPOSE: True
|
||||||
|
}
|
||||||
|
cloud_prefs._prefs[PREF_ALEXA_ENTITY_CONFIGS][entity_migrated.entity_id] = {
|
||||||
|
PREF_SHOULD_EXPOSE: True
|
||||||
|
}
|
||||||
|
conf = alexa_config.CloudAlexaConfig(
|
||||||
|
hass, ALEXA_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False)
|
||||||
|
)
|
||||||
|
await conf.async_initialize()
|
||||||
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert async_get_entity_settings(hass, "light.state_only") == {
|
||||||
|
"cloud.alexa": {"should_expose": False}
|
||||||
|
}
|
||||||
|
assert async_get_entity_settings(hass, entity_migrated.entity_id) == {
|
||||||
|
"cloud.alexa": {"should_expose": True}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_alexa_config_migrate_expose_entity_prefs_default_none(
|
async def test_alexa_config_migrate_expose_entity_prefs_default_none(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
cloud_prefs: CloudPreferences,
|
cloud_prefs: CloudPreferences,
|
||||||
|
@ -483,10 +483,12 @@ async def test_google_handle_logout(
|
|||||||
assert len(mock_enable.return_value.mock_calls) == 1
|
assert len(mock_enable.return_value.mock_calls) == 1
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize("google_settings_version", [1, 2])
|
||||||
async def test_google_config_migrate_expose_entity_prefs(
|
async def test_google_config_migrate_expose_entity_prefs(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
cloud_prefs: CloudPreferences,
|
cloud_prefs: CloudPreferences,
|
||||||
entity_registry: er.EntityRegistry,
|
entity_registry: er.EntityRegistry,
|
||||||
|
google_settings_version: int,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test migrating Google entity config."""
|
"""Test migrating Google entity config."""
|
||||||
hass.state = CoreState.starting
|
hass.state = CoreState.starting
|
||||||
@ -540,7 +542,7 @@ async def test_google_config_migrate_expose_entity_prefs(
|
|||||||
await cloud_prefs.async_update(
|
await cloud_prefs.async_update(
|
||||||
google_enabled=True,
|
google_enabled=True,
|
||||||
google_report_state=False,
|
google_report_state=False,
|
||||||
google_settings_version=1,
|
google_settings_version=google_settings_version,
|
||||||
)
|
)
|
||||||
expose_entity(hass, entity_migrated.entity_id, False)
|
expose_entity(hass, entity_migrated.entity_id, False)
|
||||||
|
|
||||||
@ -596,6 +598,100 @@ async def test_google_config_migrate_expose_entity_prefs(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_google_config_migrate_expose_entity_prefs_v2_no_exposed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cloud_prefs: CloudPreferences,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
) -> None:
|
||||||
|
"""Test migrating Google entity config from v2 to v3 when no entity is exposed."""
|
||||||
|
hass.state = CoreState.starting
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, "homeassistant", {})
|
||||||
|
hass.states.async_set("light.state_only", "on")
|
||||||
|
entity_migrated = entity_registry.async_get_or_create(
|
||||||
|
"light",
|
||||||
|
"test",
|
||||||
|
"light_migrated",
|
||||||
|
suggested_object_id="migrated",
|
||||||
|
)
|
||||||
|
await cloud_prefs.async_update(
|
||||||
|
google_enabled=True,
|
||||||
|
google_report_state=False,
|
||||||
|
google_settings_version=2,
|
||||||
|
)
|
||||||
|
expose_entity(hass, "light.state_only", False)
|
||||||
|
expose_entity(hass, entity_migrated.entity_id, False)
|
||||||
|
|
||||||
|
cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.state_only"] = {
|
||||||
|
PREF_SHOULD_EXPOSE: True
|
||||||
|
}
|
||||||
|
cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_migrated.entity_id] = {
|
||||||
|
PREF_SHOULD_EXPOSE: True
|
||||||
|
}
|
||||||
|
conf = CloudGoogleConfig(
|
||||||
|
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False)
|
||||||
|
)
|
||||||
|
await conf.async_initialize()
|
||||||
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert async_get_entity_settings(hass, "light.state_only") == {
|
||||||
|
"cloud.google_assistant": {"should_expose": True}
|
||||||
|
}
|
||||||
|
assert async_get_entity_settings(hass, entity_migrated.entity_id) == {
|
||||||
|
"cloud.google_assistant": {"should_expose": True}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_google_config_migrate_expose_entity_prefs_v2_exposed(
|
||||||
|
hass: HomeAssistant,
|
||||||
|
cloud_prefs: CloudPreferences,
|
||||||
|
entity_registry: er.EntityRegistry,
|
||||||
|
) -> None:
|
||||||
|
"""Test migrating Google entity config from v2 to v3 when an entity is exposed."""
|
||||||
|
hass.state = CoreState.starting
|
||||||
|
|
||||||
|
assert await async_setup_component(hass, "homeassistant", {})
|
||||||
|
hass.states.async_set("light.state_only", "on")
|
||||||
|
entity_migrated = entity_registry.async_get_or_create(
|
||||||
|
"light",
|
||||||
|
"test",
|
||||||
|
"light_migrated",
|
||||||
|
suggested_object_id="migrated",
|
||||||
|
)
|
||||||
|
await cloud_prefs.async_update(
|
||||||
|
google_enabled=True,
|
||||||
|
google_report_state=False,
|
||||||
|
google_settings_version=2,
|
||||||
|
)
|
||||||
|
expose_entity(hass, "light.state_only", False)
|
||||||
|
expose_entity(hass, entity_migrated.entity_id, True)
|
||||||
|
|
||||||
|
cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS]["light.state_only"] = {
|
||||||
|
PREF_SHOULD_EXPOSE: True
|
||||||
|
}
|
||||||
|
cloud_prefs._prefs[PREF_GOOGLE_ENTITY_CONFIGS][entity_migrated.entity_id] = {
|
||||||
|
PREF_SHOULD_EXPOSE: True
|
||||||
|
}
|
||||||
|
conf = CloudGoogleConfig(
|
||||||
|
hass, GACTIONS_SCHEMA({}), "mock-user-id", cloud_prefs, Mock(is_logged_in=False)
|
||||||
|
)
|
||||||
|
await conf.async_initialize()
|
||||||
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_START)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
hass.bus.async_fire(EVENT_HOMEASSISTANT_STARTED)
|
||||||
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
|
assert async_get_entity_settings(hass, "light.state_only") == {
|
||||||
|
"cloud.google_assistant": {"should_expose": False}
|
||||||
|
}
|
||||||
|
assert async_get_entity_settings(hass, entity_migrated.entity_id) == {
|
||||||
|
"cloud.google_assistant": {"should_expose": True}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_google_config_migrate_expose_entity_prefs_default_none(
|
async def test_google_config_migrate_expose_entity_prefs_default_none(
|
||||||
hass: HomeAssistant,
|
hass: HomeAssistant,
|
||||||
cloud_prefs: CloudPreferences,
|
cloud_prefs: CloudPreferences,
|
||||||
|
@ -98,8 +98,8 @@ def setup_mock_onvif_camera(
|
|||||||
)
|
)
|
||||||
else:
|
else:
|
||||||
mock_onvif_camera.update_xaddrs = AsyncMock(return_value=True)
|
mock_onvif_camera.update_xaddrs = AsyncMock(return_value=True)
|
||||||
mock_onvif_camera.create_devicemgmt_service = MagicMock(return_value=devicemgmt)
|
mock_onvif_camera.create_devicemgmt_service = AsyncMock(return_value=devicemgmt)
|
||||||
mock_onvif_camera.create_media_service = MagicMock(return_value=media_service)
|
mock_onvif_camera.create_media_service = AsyncMock(return_value=media_service)
|
||||||
mock_onvif_camera.close = AsyncMock(return_value=None)
|
mock_onvif_camera.close = AsyncMock(return_value=None)
|
||||||
|
|
||||||
def mock_constructor(
|
def mock_constructor(
|
||||||
|
@ -27,7 +27,7 @@ async def test_reboot_button(hass: HomeAssistant) -> None:
|
|||||||
async def test_reboot_button_press(hass: HomeAssistant) -> None:
|
async def test_reboot_button_press(hass: HomeAssistant) -> None:
|
||||||
"""Test Reboot button press."""
|
"""Test Reboot button press."""
|
||||||
_, camera, _ = await setup_onvif_integration(hass)
|
_, camera, _ = await setup_onvif_integration(hass)
|
||||||
devicemgmt = camera.create_devicemgmt_service()
|
devicemgmt = await camera.create_devicemgmt_service()
|
||||||
devicemgmt.SystemReboot = AsyncMock(return_value=True)
|
devicemgmt.SystemReboot = AsyncMock(return_value=True)
|
||||||
|
|
||||||
await hass.services.async_call(
|
await hass.services.async_call(
|
||||||
|
@ -102,7 +102,7 @@ INDEXED_SENSOR_CONFIG_2 = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
NESTED_SENSOR_CONFIG = {
|
NESTED_SENSOR_CONFIG_1 = {
|
||||||
"sn": {
|
"sn": {
|
||||||
"Time": "2020-03-03T00:00:00+00:00",
|
"Time": "2020-03-03T00:00:00+00:00",
|
||||||
"TX23": {
|
"TX23": {
|
||||||
@ -119,6 +119,17 @@ NESTED_SENSOR_CONFIG = {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
NESTED_SENSOR_CONFIG_2 = {
|
||||||
|
"sn": {
|
||||||
|
"Time": "2023-01-27T11:04:56",
|
||||||
|
"DS18B20": {
|
||||||
|
"Id": "01191ED79190",
|
||||||
|
"Temperature": 2.4,
|
||||||
|
},
|
||||||
|
"TempUnit": "C",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
async def test_controlling_state_via_mqtt(
|
async def test_controlling_state_via_mqtt(
|
||||||
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
|
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
|
||||||
@ -174,12 +185,59 @@ async def test_controlling_state_via_mqtt(
|
|||||||
assert state.state == "20.0"
|
assert state.state == "20.0"
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("sensor_config", "entity_ids", "messages", "states"),
|
||||||
|
[
|
||||||
|
(
|
||||||
|
NESTED_SENSOR_CONFIG_1,
|
||||||
|
["sensor.tasmota_tx23_speed_act", "sensor.tasmota_tx23_dir_card"],
|
||||||
|
(
|
||||||
|
'{"TX23":{"Speed":{"Act":"12.3"},"Dir": {"Card": "WSW"}}}',
|
||||||
|
'{"StatusSNS":{"TX23":{"Speed":{"Act":"23.4"},"Dir": {"Card": "ESE"}}}}',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"sensor.tasmota_tx23_speed_act": "12.3",
|
||||||
|
"sensor.tasmota_tx23_dir_card": "WSW",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sensor.tasmota_tx23_speed_act": "23.4",
|
||||||
|
"sensor.tasmota_tx23_dir_card": "ESE",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
NESTED_SENSOR_CONFIG_2,
|
||||||
|
["sensor.tasmota_ds18b20_temperature", "sensor.tasmota_ds18b20_id"],
|
||||||
|
(
|
||||||
|
'{"DS18B20":{"Id": "01191ED79190","Temperature": 12.3}}',
|
||||||
|
'{"StatusSNS":{"DS18B20":{"Id": "meep","Temperature": 23.4}}}',
|
||||||
|
),
|
||||||
|
(
|
||||||
|
{
|
||||||
|
"sensor.tasmota_ds18b20_temperature": "12.3",
|
||||||
|
"sensor.tasmota_ds18b20_id": "01191ED79190",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"sensor.tasmota_ds18b20_temperature": "23.4",
|
||||||
|
"sensor.tasmota_ds18b20_id": "meep",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
async def test_nested_sensor_state_via_mqtt(
|
async def test_nested_sensor_state_via_mqtt(
|
||||||
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
|
hass: HomeAssistant,
|
||||||
|
mqtt_mock: MqttMockHAClient,
|
||||||
|
setup_tasmota,
|
||||||
|
sensor_config,
|
||||||
|
entity_ids,
|
||||||
|
messages,
|
||||||
|
states,
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Test state update via MQTT."""
|
"""Test state update via MQTT."""
|
||||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||||
sensor_config = copy.deepcopy(NESTED_SENSOR_CONFIG)
|
sensor_config = copy.deepcopy(sensor_config)
|
||||||
mac = config["mac"]
|
mac = config["mac"]
|
||||||
|
|
||||||
async_fire_mqtt_message(
|
async_fire_mqtt_message(
|
||||||
@ -195,31 +253,29 @@ async def test_nested_sensor_state_via_mqtt(
|
|||||||
)
|
)
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
|
|
||||||
state = hass.states.get("sensor.tasmota_tx23_speed_act")
|
for entity_id in entity_ids:
|
||||||
assert state.state == "unavailable"
|
state = hass.states.get(entity_id)
|
||||||
assert not state.attributes.get(ATTR_ASSUMED_STATE)
|
assert state.state == "unavailable"
|
||||||
|
assert not state.attributes.get(ATTR_ASSUMED_STATE)
|
||||||
|
|
||||||
async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online")
|
async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online")
|
||||||
await hass.async_block_till_done()
|
await hass.async_block_till_done()
|
||||||
state = hass.states.get("sensor.tasmota_tx23_speed_act")
|
for entity_id in entity_ids:
|
||||||
assert state.state == STATE_UNKNOWN
|
state = hass.states.get(entity_id)
|
||||||
assert not state.attributes.get(ATTR_ASSUMED_STATE)
|
assert state.state == STATE_UNKNOWN
|
||||||
|
assert not state.attributes.get(ATTR_ASSUMED_STATE)
|
||||||
|
|
||||||
# Test periodic state update
|
# Test periodic state update
|
||||||
async_fire_mqtt_message(
|
async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/SENSOR", messages[0])
|
||||||
hass, "tasmota_49A3BC/tele/SENSOR", '{"TX23":{"Speed":{"Act":"12.3"}}}'
|
for entity_id in entity_ids:
|
||||||
)
|
state = hass.states.get(entity_id)
|
||||||
state = hass.states.get("sensor.tasmota_tx23_speed_act")
|
assert state.state == states[0][entity_id]
|
||||||
assert state.state == "12.3"
|
|
||||||
|
|
||||||
# Test polled state update
|
# Test polled state update
|
||||||
async_fire_mqtt_message(
|
async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/STATUS10", messages[1])
|
||||||
hass,
|
for entity_id in entity_ids:
|
||||||
"tasmota_49A3BC/stat/STATUS10",
|
state = hass.states.get(entity_id)
|
||||||
'{"StatusSNS":{"TX23":{"Speed":{"Act":"23.4"}}}}',
|
assert state.state == states[1][entity_id]
|
||||||
)
|
|
||||||
state = hass.states.get("sensor.tasmota_tx23_speed_act")
|
|
||||||
assert state.state == "23.4"
|
|
||||||
|
|
||||||
|
|
||||||
async def test_indexed_sensor_state_via_mqtt(
|
async def test_indexed_sensor_state_via_mqtt(
|
||||||
@ -728,7 +784,7 @@ async def test_nested_sensor_attributes(
|
|||||||
) -> None:
|
) -> None:
|
||||||
"""Test correct attributes for sensors."""
|
"""Test correct attributes for sensors."""
|
||||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||||
sensor_config = copy.deepcopy(NESTED_SENSOR_CONFIG)
|
sensor_config = copy.deepcopy(NESTED_SENSOR_CONFIG_1)
|
||||||
mac = config["mac"]
|
mac = config["mac"]
|
||||||
|
|
||||||
async_fire_mqtt_message(
|
async_fire_mqtt_message(
|
||||||
@ -754,7 +810,7 @@ async def test_nested_sensor_attributes(
|
|||||||
assert state.attributes.get("device_class") is None
|
assert state.attributes.get("device_class") is None
|
||||||
assert state.attributes.get("friendly_name") == "Tasmota TX23 Dir Avg"
|
assert state.attributes.get("friendly_name") == "Tasmota TX23 Dir Avg"
|
||||||
assert state.attributes.get("icon") is None
|
assert state.attributes.get("icon") is None
|
||||||
assert state.attributes.get("unit_of_measurement") == " "
|
assert state.attributes.get("unit_of_measurement") is None
|
||||||
|
|
||||||
|
|
||||||
async def test_indexed_sensor_attributes(
|
async def test_indexed_sensor_attributes(
|
||||||
|
@ -2,7 +2,11 @@
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from transmission_rpc.error import TransmissionError
|
from transmission_rpc.error import (
|
||||||
|
TransmissionAuthError,
|
||||||
|
TransmissionConnectError,
|
||||||
|
TransmissionError,
|
||||||
|
)
|
||||||
|
|
||||||
from homeassistant import config_entries
|
from homeassistant import config_entries
|
||||||
from homeassistant.components import transmission
|
from homeassistant.components import transmission
|
||||||
@ -125,7 +129,7 @@ async def test_error_on_wrong_credentials(
|
|||||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_api.side_effect = TransmissionError("401: Unauthorized")
|
mock_api.side_effect = TransmissionAuthError()
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
MOCK_CONFIG_DATA,
|
MOCK_CONFIG_DATA,
|
||||||
@ -137,6 +141,21 @@ async def test_error_on_wrong_credentials(
|
|||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def test_unexpected_error(hass: HomeAssistant, mock_api: MagicMock) -> None:
|
||||||
|
"""Test we handle unexpected error."""
|
||||||
|
result = await hass.config_entries.flow.async_init(
|
||||||
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_api.side_effect = TransmissionError()
|
||||||
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
|
result["flow_id"],
|
||||||
|
MOCK_CONFIG_DATA,
|
||||||
|
)
|
||||||
|
assert result2["type"] == FlowResultType.FORM
|
||||||
|
assert result2["errors"] == {"base": "cannot_connect"}
|
||||||
|
|
||||||
|
|
||||||
async def test_error_on_connection_failure(
|
async def test_error_on_connection_failure(
|
||||||
hass: HomeAssistant, mock_api: MagicMock
|
hass: HomeAssistant, mock_api: MagicMock
|
||||||
) -> None:
|
) -> None:
|
||||||
@ -145,7 +164,7 @@ async def test_error_on_connection_failure(
|
|||||||
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
DOMAIN, context={"source": config_entries.SOURCE_USER}
|
||||||
)
|
)
|
||||||
|
|
||||||
mock_api.side_effect = TransmissionError("111: Connection refused")
|
mock_api.side_effect = TransmissionConnectError()
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
MOCK_CONFIG_DATA,
|
MOCK_CONFIG_DATA,
|
||||||
@ -213,7 +232,7 @@ async def test_reauth_failed(hass: HomeAssistant, mock_api: MagicMock) -> None:
|
|||||||
assert result["step_id"] == "reauth_confirm"
|
assert result["step_id"] == "reauth_confirm"
|
||||||
assert result["description_placeholders"] == {"username": "user"}
|
assert result["description_placeholders"] == {"username": "user"}
|
||||||
|
|
||||||
mock_api.side_effect = TransmissionError("401: Unauthorized")
|
mock_api.side_effect = TransmissionAuthError()
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
{
|
{
|
||||||
@ -248,7 +267,7 @@ async def test_reauth_failed_connection_error(
|
|||||||
assert result["step_id"] == "reauth_confirm"
|
assert result["step_id"] == "reauth_confirm"
|
||||||
assert result["description_placeholders"] == {"username": "user"}
|
assert result["description_placeholders"] == {"username": "user"}
|
||||||
|
|
||||||
mock_api.side_effect = TransmissionError("111: Connection refused")
|
mock_api.side_effect = TransmissionConnectError()
|
||||||
result2 = await hass.config_entries.flow.async_configure(
|
result2 = await hass.config_entries.flow.async_configure(
|
||||||
result["flow_id"],
|
result["flow_id"],
|
||||||
{
|
{
|
||||||
|
@ -3,7 +3,11 @@
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
from transmission_rpc.error import TransmissionError
|
from transmission_rpc.error import (
|
||||||
|
TransmissionAuthError,
|
||||||
|
TransmissionConnectError,
|
||||||
|
TransmissionError,
|
||||||
|
)
|
||||||
|
|
||||||
from homeassistant.components.transmission.const import DOMAIN
|
from homeassistant.components.transmission.const import DOMAIN
|
||||||
from homeassistant.config_entries import ConfigEntryState
|
from homeassistant.config_entries import ConfigEntryState
|
||||||
@ -40,7 +44,7 @@ async def test_setup_failed_connection_error(
|
|||||||
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA)
|
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA)
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
mock_api.side_effect = TransmissionError("111: Connection refused")
|
mock_api.side_effect = TransmissionConnectError()
|
||||||
|
|
||||||
await hass.config_entries.async_setup(entry.entry_id)
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
assert entry.state == ConfigEntryState.SETUP_RETRY
|
assert entry.state == ConfigEntryState.SETUP_RETRY
|
||||||
@ -54,7 +58,21 @@ async def test_setup_failed_auth_error(
|
|||||||
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA)
|
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA)
|
||||||
entry.add_to_hass(hass)
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
mock_api.side_effect = TransmissionError("401: Unauthorized")
|
mock_api.side_effect = TransmissionAuthError()
|
||||||
|
|
||||||
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
|
assert entry.state == ConfigEntryState.SETUP_ERROR
|
||||||
|
|
||||||
|
|
||||||
|
async def test_setup_failed_unexpected_error(
|
||||||
|
hass: HomeAssistant, mock_api: MagicMock
|
||||||
|
) -> None:
|
||||||
|
"""Test integration failed due to unexpected error."""
|
||||||
|
|
||||||
|
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA)
|
||||||
|
entry.add_to_hass(hass)
|
||||||
|
|
||||||
|
mock_api.side_effect = TransmissionError()
|
||||||
|
|
||||||
await hass.config_entries.async_setup(entry.entry_id)
|
await hass.config_entries.async_setup(entry.entry_id)
|
||||||
assert entry.state == ConfigEntryState.SETUP_ERROR
|
assert entry.state == ConfigEntryState.SETUP_ERROR
|
||||||
|
@ -1,11 +1,13 @@
|
|||||||
"""Test ZHA Core cluster handlers."""
|
"""Test ZHA Core cluster handlers."""
|
||||||
import asyncio
|
import asyncio
|
||||||
from collections.abc import Callable
|
from collections.abc import Callable
|
||||||
|
import logging
|
||||||
import math
|
import math
|
||||||
from unittest import mock
|
from unittest import mock
|
||||||
from unittest.mock import AsyncMock, patch
|
from unittest.mock import AsyncMock, patch
|
||||||
|
|
||||||
import pytest
|
import pytest
|
||||||
|
import zigpy.device
|
||||||
import zigpy.endpoint
|
import zigpy.endpoint
|
||||||
from zigpy.endpoint import Endpoint as ZigpyEndpoint
|
from zigpy.endpoint import Endpoint as ZigpyEndpoint
|
||||||
import zigpy.profiles.zha
|
import zigpy.profiles.zha
|
||||||
@ -791,3 +793,41 @@ async def test_configure_reporting(hass: HomeAssistant, endpoint) -> None:
|
|||||||
}
|
}
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
async def test_invalid_cluster_handler(hass: HomeAssistant, caplog) -> None:
|
||||||
|
"""Test setting up a cluster handler that fails to match properly."""
|
||||||
|
|
||||||
|
class TestZigbeeClusterHandler(cluster_handlers.ClusterHandler):
|
||||||
|
REPORT_CONFIG = (
|
||||||
|
cluster_handlers.AttrReportConfig(attr="missing_attr", config=(1, 60, 1)),
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_device = mock.AsyncMock(spec_set=zigpy.device.Device)
|
||||||
|
zigpy_ep = zigpy.endpoint.Endpoint(mock_device, endpoint_id=1)
|
||||||
|
|
||||||
|
cluster = zigpy_ep.add_input_cluster(zigpy.zcl.clusters.lighting.Color.cluster_id)
|
||||||
|
cluster.configure_reporting_multiple = AsyncMock(
|
||||||
|
spec_set=cluster.configure_reporting_multiple,
|
||||||
|
return_value=[
|
||||||
|
foundation.ConfigureReportingResponseRecord(
|
||||||
|
status=foundation.Status.SUCCESS
|
||||||
|
)
|
||||||
|
],
|
||||||
|
)
|
||||||
|
|
||||||
|
mock_zha_device = mock.AsyncMock(spec_set=ZHADevice)
|
||||||
|
zha_endpoint = Endpoint(zigpy_ep, mock_zha_device)
|
||||||
|
|
||||||
|
# The cluster handler throws an error when matching this cluster
|
||||||
|
with pytest.raises(KeyError):
|
||||||
|
TestZigbeeClusterHandler(cluster, zha_endpoint)
|
||||||
|
|
||||||
|
# And one is also logged at runtime
|
||||||
|
with patch.dict(
|
||||||
|
registries.ZIGBEE_CLUSTER_HANDLER_REGISTRY,
|
||||||
|
{cluster.cluster_id: TestZigbeeClusterHandler},
|
||||||
|
), caplog.at_level(logging.WARNING):
|
||||||
|
zha_endpoint.add_all_cluster_handlers()
|
||||||
|
|
||||||
|
assert "missing_attr" in caplog.text
|
||||||
|
Loading…
x
Reference in New Issue
Block a user