forked from home-assistant/core
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e0a97ec90d | ||
|
|
1f6a601fc9 | ||
|
|
ff14277805 | ||
|
|
6424dee231 | ||
|
|
13c51e9c34 | ||
|
|
304c34a119 | ||
|
|
d840d27f2d | ||
|
|
a8cf3fadaa | ||
|
|
a3f3b43c20 | ||
|
|
b0520ccb94 | ||
|
|
fe308e26dc | ||
|
|
60fb71159d | ||
|
|
413dbe89e5 | ||
|
|
7abe9f1f9a | ||
|
|
252b99f00b | ||
|
|
8e407334b7 | ||
|
|
91faa31e8c | ||
|
|
5e77de35bd | ||
|
|
c1b18dcbba | ||
|
|
3c45bda0e8 | ||
|
|
7361c29cba | ||
|
|
a551de06c7 | ||
|
|
84ce2f13f2 | ||
|
|
5c949bd862 | ||
|
|
16020d8ab9 | ||
|
|
f866d6100d | ||
|
|
8d0da78fab | ||
|
|
7173a4f377 | ||
|
|
d4acb2a381 | ||
|
|
b1111eb2c7 | ||
|
|
4895ca218f | ||
|
|
91e9d21548 | ||
|
|
996c6c4a92 | ||
|
|
96ff24aa2f | ||
|
|
dcc5940f9b | ||
|
|
dd51bba677 | ||
|
|
ac9da5c167 | ||
|
|
e904edb12e | ||
|
|
ddebfb3ac5 | ||
|
|
fe57901b5f | ||
|
|
73d4c73dbb | ||
|
|
f1bccef224 | ||
|
|
cf243fbe11 | ||
|
|
35c48d3d0e | ||
|
|
15ef53cd9a | ||
|
|
fb29e1a14e | ||
|
|
f8c3586f6b | ||
|
|
e8808b5fe7 | ||
|
|
82c0967716 | ||
|
|
163823d2a5 | ||
|
|
2dd1ce2047 | ||
|
|
241cacde62 | ||
|
|
8a11ee81c4 | ||
|
|
e3762724a3 | ||
|
|
b973825833 | ||
|
|
b2fcbbe50e | ||
|
|
d96b37a004 | ||
|
|
affece8857 | ||
|
|
bce18bf61a | ||
|
|
eda0731e60 | ||
|
|
238c87055f | ||
|
|
4b4464a3de | ||
|
|
a07fbdd61c | ||
|
|
3126ebe9d6 | ||
|
|
89aec9d356 | ||
|
|
0cfa566ff6 | ||
|
|
fffece95f5 |
@@ -783,6 +783,7 @@ build.json @home-assistant/supervisor
|
||||
/homeassistant/components/netdata/ @fabaff
|
||||
/homeassistant/components/netgear/ @hacf-fr @Quentame @starkillerOG
|
||||
/tests/components/netgear/ @hacf-fr @Quentame @starkillerOG
|
||||
/homeassistant/components/netgear_lte/ @tkdrob
|
||||
/homeassistant/components/network/ @home-assistant/core
|
||||
/tests/components/network/ @home-assistant/core
|
||||
/homeassistant/components/nexia/ @bdraco
|
||||
|
||||
@@ -3,12 +3,12 @@ from __future__ import annotations
|
||||
|
||||
from typing import Any, Final
|
||||
|
||||
from aioairzone.common import OperationMode
|
||||
from aioairzone.common import OperationAction, OperationMode
|
||||
from aioairzone.const import (
|
||||
API_MODE,
|
||||
API_ON,
|
||||
API_SET_POINT,
|
||||
AZD_DEMAND,
|
||||
AZD_ACTION,
|
||||
AZD_HUMIDITY,
|
||||
AZD_MASTER,
|
||||
AZD_MODE,
|
||||
@@ -39,12 +39,13 @@ from .const import API_TEMPERATURE_STEP, DOMAIN, TEMP_UNIT_LIB_TO_HASS
|
||||
from .coordinator import AirzoneUpdateCoordinator
|
||||
from .entity import AirzoneZoneEntity
|
||||
|
||||
HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationMode, HVACAction]] = {
|
||||
OperationMode.STOP: HVACAction.OFF,
|
||||
OperationMode.COOLING: HVACAction.COOLING,
|
||||
OperationMode.HEATING: HVACAction.HEATING,
|
||||
OperationMode.FAN: HVACAction.FAN,
|
||||
OperationMode.DRY: HVACAction.DRYING,
|
||||
HVAC_ACTION_LIB_TO_HASS: Final[dict[OperationAction, HVACAction]] = {
|
||||
OperationAction.COOLING: HVACAction.COOLING,
|
||||
OperationAction.DRYING: HVACAction.DRYING,
|
||||
OperationAction.FAN: HVACAction.FAN,
|
||||
OperationAction.HEATING: HVACAction.HEATING,
|
||||
OperationAction.IDLE: HVACAction.IDLE,
|
||||
OperationAction.OFF: HVACAction.OFF,
|
||||
}
|
||||
HVAC_MODE_LIB_TO_HASS: Final[dict[OperationMode, HVACMode]] = {
|
||||
OperationMode.STOP: HVACMode.OFF,
|
||||
@@ -156,14 +157,13 @@ class AirzoneClimate(AirzoneZoneEntity, ClimateEntity):
|
||||
"""Update climate attributes."""
|
||||
self._attr_current_temperature = self.get_airzone_value(AZD_TEMP)
|
||||
self._attr_current_humidity = self.get_airzone_value(AZD_HUMIDITY)
|
||||
self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[
|
||||
self.get_airzone_value(AZD_ACTION)
|
||||
]
|
||||
if self.get_airzone_value(AZD_ON):
|
||||
mode = self.get_airzone_value(AZD_MODE)
|
||||
self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[mode]
|
||||
if self.get_airzone_value(AZD_DEMAND):
|
||||
self._attr_hvac_action = HVAC_ACTION_LIB_TO_HASS[mode]
|
||||
else:
|
||||
self._attr_hvac_action = HVACAction.IDLE
|
||||
self._attr_hvac_mode = HVAC_MODE_LIB_TO_HASS[
|
||||
self.get_airzone_value(AZD_MODE)
|
||||
]
|
||||
else:
|
||||
self._attr_hvac_action = HVACAction.OFF
|
||||
self._attr_hvac_mode = HVACMode.OFF
|
||||
self._attr_target_temperature = self.get_airzone_value(AZD_TEMP_SET)
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/airzone",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["aioairzone"],
|
||||
"requirements": ["aioairzone==0.5.2"]
|
||||
"requirements": ["aioairzone==0.5.5"]
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@ from abc import ABC, abstractmethod
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from homeassistant.core import CALLBACK_TYPE, callback
|
||||
from homeassistant.core import CALLBACK_TYPE, HomeAssistant, callback
|
||||
from homeassistant.helpers.storage import Store
|
||||
|
||||
from .const import DOMAIN
|
||||
@@ -17,11 +17,12 @@ _LOGGER = logging.getLogger(__name__)
|
||||
class AbstractConfig(ABC):
|
||||
"""Hold the configuration for Alexa."""
|
||||
|
||||
_unsub_proactive_report: asyncio.Task[CALLBACK_TYPE] | None = None
|
||||
_unsub_proactive_report: CALLBACK_TYPE | None = None
|
||||
|
||||
def __init__(self, hass):
|
||||
def __init__(self, hass: HomeAssistant) -> None:
|
||||
"""Initialize abstract config."""
|
||||
self.hass = hass
|
||||
self._enable_proactive_mode_lock = asyncio.Lock()
|
||||
self._store = None
|
||||
|
||||
async def async_initialize(self):
|
||||
@@ -67,20 +68,17 @@ class AbstractConfig(ABC):
|
||||
async def async_enable_proactive_mode(self):
|
||||
"""Enable proactive mode."""
|
||||
_LOGGER.debug("Enable proactive mode")
|
||||
if self._unsub_proactive_report is None:
|
||||
self._unsub_proactive_report = self.hass.async_create_task(
|
||||
async_enable_proactive_mode(self.hass, self)
|
||||
async with self._enable_proactive_mode_lock:
|
||||
if self._unsub_proactive_report is not None:
|
||||
return
|
||||
self._unsub_proactive_report = await async_enable_proactive_mode(
|
||||
self.hass, self
|
||||
)
|
||||
try:
|
||||
await self._unsub_proactive_report
|
||||
except Exception:
|
||||
self._unsub_proactive_report = None
|
||||
raise
|
||||
|
||||
async def async_disable_proactive_mode(self):
|
||||
"""Disable proactive mode."""
|
||||
_LOGGER.debug("Disable proactive mode")
|
||||
if unsub_func := await self._unsub_proactive_report:
|
||||
if unsub_func := self._unsub_proactive_report:
|
||||
unsub_func()
|
||||
self._unsub_proactive_report = None
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
"bleak==0.20.2",
|
||||
"bleak-retry-connector==3.0.2",
|
||||
"bluetooth-adapters==0.15.3",
|
||||
"bluetooth-auto-recovery==1.1.1",
|
||||
"bluetooth-auto-recovery==1.2.0",
|
||||
"bluetooth-data-tools==0.4.0",
|
||||
"dbus-fast==1.85.0"
|
||||
]
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/bmw_connected_drive",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["bimmer_connected"],
|
||||
"requirements": ["bimmer_connected==0.13.2"]
|
||||
"requirements": ["bimmer_connected==0.13.3"]
|
||||
}
|
||||
|
||||
@@ -24,7 +24,6 @@ from homeassistant.components.binary_sensor import BinarySensorDeviceClass
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_expose_entity,
|
||||
async_get_assistant_settings,
|
||||
async_get_entity_settings,
|
||||
async_listen_entity_updates,
|
||||
async_should_expose,
|
||||
)
|
||||
@@ -200,22 +199,10 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
# Don't migrate if there's a YAML config
|
||||
return
|
||||
|
||||
for state in self.hass.states.async_all():
|
||||
with suppress(HomeAssistantError):
|
||||
entity_settings = async_get_entity_settings(self.hass, state.entity_id)
|
||||
if CLOUD_ALEXA in entity_settings:
|
||||
continue
|
||||
async_expose_entity(
|
||||
self.hass,
|
||||
CLOUD_ALEXA,
|
||||
state.entity_id,
|
||||
self._should_expose_legacy(state.entity_id),
|
||||
)
|
||||
for entity_id in self._prefs.alexa_entity_configs:
|
||||
with suppress(HomeAssistantError):
|
||||
entity_settings = async_get_entity_settings(self.hass, entity_id)
|
||||
if CLOUD_ALEXA in entity_settings:
|
||||
continue
|
||||
for entity_id in {
|
||||
*self.hass.states.async_entity_ids(),
|
||||
*self._prefs.alexa_entity_configs,
|
||||
}:
|
||||
async_expose_entity(
|
||||
self.hass,
|
||||
CLOUD_ALEXA,
|
||||
@@ -229,8 +216,18 @@ class CloudAlexaConfig(alexa_config.AbstractConfig):
|
||||
|
||||
async def on_hass_started(hass):
|
||||
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()
|
||||
|
||||
await self._prefs.async_update(
|
||||
alexa_settings_version=ALEXA_SETTINGS_VERSION
|
||||
)
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
"""Google config for Cloud."""
|
||||
import asyncio
|
||||
from contextlib import suppress
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
from typing import Any
|
||||
@@ -13,6 +12,7 @@ from homeassistant.components.google_assistant import DOMAIN as GOOGLE_DOMAIN
|
||||
from homeassistant.components.google_assistant.helpers import AbstractConfig
|
||||
from homeassistant.components.homeassistant.exposed_entities import (
|
||||
async_expose_entity,
|
||||
async_get_assistant_settings,
|
||||
async_get_entity_settings,
|
||||
async_listen_entity_updates,
|
||||
async_set_assistant_option,
|
||||
@@ -176,31 +176,10 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
# Don't migrate if there's a YAML config
|
||||
return
|
||||
|
||||
for state in self.hass.states.async_all():
|
||||
entity_id = state.entity_id
|
||||
with suppress(HomeAssistantError):
|
||||
entity_settings = async_get_entity_settings(self.hass, entity_id)
|
||||
if CLOUD_GOOGLE in entity_settings:
|
||||
continue
|
||||
async_expose_entity(
|
||||
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:
|
||||
with suppress(HomeAssistantError):
|
||||
entity_settings = async_get_entity_settings(self.hass, entity_id)
|
||||
if CLOUD_GOOGLE in entity_settings:
|
||||
continue
|
||||
for entity_id in {
|
||||
*self.hass.states.async_entity_ids(),
|
||||
*self._prefs.google_entity_configs,
|
||||
}:
|
||||
async_expose_entity(
|
||||
self.hass,
|
||||
CLOUD_GOOGLE,
|
||||
@@ -222,8 +201,18 @@ class CloudGoogleConfig(AbstractConfig):
|
||||
|
||||
async def on_hass_started(hass: HomeAssistant) -> None:
|
||||
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()
|
||||
|
||||
await self._prefs.async_update(
|
||||
google_settings_version=GOOGLE_SETTINGS_VERSION
|
||||
)
|
||||
|
||||
@@ -41,8 +41,8 @@ STORAGE_KEY = DOMAIN
|
||||
STORAGE_VERSION = 1
|
||||
STORAGE_VERSION_MINOR = 2
|
||||
|
||||
ALEXA_SETTINGS_VERSION = 2
|
||||
GOOGLE_SETTINGS_VERSION = 2
|
||||
ALEXA_SETTINGS_VERSION = 3
|
||||
GOOGLE_SETTINGS_VERSION = 3
|
||||
|
||||
|
||||
class CloudPreferencesStore(Store):
|
||||
|
||||
@@ -15,5 +15,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/elkm1",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["elkm1_lib"],
|
||||
"requirements": ["elkm1-lib==2.2.1"]
|
||||
"requirements": ["elkm1-lib==2.2.2"]
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ from aioesphomeapi import (
|
||||
NumberInfo,
|
||||
SelectInfo,
|
||||
SensorInfo,
|
||||
SensorState,
|
||||
SwitchInfo,
|
||||
TextSensorInfo,
|
||||
UserService,
|
||||
@@ -240,9 +241,18 @@ class RuntimeEntryData:
|
||||
current_state_by_type = self.state[state_type]
|
||||
current_state = current_state_by_type.get(key, _SENTINEL)
|
||||
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(
|
||||
"%s: ignoring duplicate update with and key %s: %s",
|
||||
"%s: ignoring duplicate update with key %s: %s",
|
||||
self.name,
|
||||
key,
|
||||
state,
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aioesphomeapi", "noiseprotocol"],
|
||||
"requirements": [
|
||||
"aioesphomeapi==13.7.2",
|
||||
"aioesphomeapi==13.7.4",
|
||||
"bluetooth-data-tools==0.4.0",
|
||||
"esphome-dashboard-api==1.2.3"
|
||||
],
|
||||
|
||||
@@ -283,7 +283,7 @@ class FritzBoxTools(
|
||||
entity_data["entity_states"][
|
||||
key
|
||||
] = await self.hass.async_add_executor_job(
|
||||
update_fn, self.fritz_status, self.data.get(key)
|
||||
update_fn, self.fritz_status, self.data["entity_states"].get(key)
|
||||
)
|
||||
if self.has_call_deflections:
|
||||
entity_data[
|
||||
|
||||
@@ -20,5 +20,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/frontend",
|
||||
"integration_type": "system",
|
||||
"quality_scale": "internal",
|
||||
"requirements": ["home-assistant-frontend==20230503.1"]
|
||||
"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)
|
||||
|
||||
# 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."""
|
||||
if (os_info := get_os_info(hass)) is None:
|
||||
# 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.config_entries.flow.async_init(DOMAIN, context={"source": "system"})
|
||||
|
||||
@@ -174,23 +174,23 @@ class IntegrationSensor(RestoreEntity, SensorEntity):
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
if state := await self.async_get_last_state():
|
||||
try:
|
||||
self._state = Decimal(state.state)
|
||||
except (DecimalException, ValueError) as err:
|
||||
_LOGGER.warning(
|
||||
"%s could not restore last state %s: %s",
|
||||
self.entity_id,
|
||||
state.state,
|
||||
err,
|
||||
)
|
||||
else:
|
||||
self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
if self._unit_of_measurement is None:
|
||||
self._unit_of_measurement = state.attributes.get(
|
||||
ATTR_UNIT_OF_MEASUREMENT
|
||||
if (state := await self.async_get_last_state()) is not None:
|
||||
if state.state == STATE_UNAVAILABLE:
|
||||
self._attr_available = False
|
||||
elif state.state != STATE_UNKNOWN:
|
||||
try:
|
||||
self._state = Decimal(state.state)
|
||||
except (DecimalException, ValueError) as err:
|
||||
_LOGGER.warning(
|
||||
"%s could not restore last state %s: %s",
|
||||
self.entity_id,
|
||||
state.state,
|
||||
err,
|
||||
)
|
||||
|
||||
self._attr_device_class = state.attributes.get(ATTR_DEVICE_CLASS)
|
||||
self._unit_of_measurement = state.attributes.get(ATTR_UNIT_OF_MEASUREMENT)
|
||||
|
||||
@callback
|
||||
def calc_integration(event: Event) -> None:
|
||||
"""Handle the sensor state changes."""
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import Any, cast
|
||||
|
||||
from aiolifx.aiolifx import (
|
||||
Light,
|
||||
Message,
|
||||
MultiZoneDirection,
|
||||
MultiZoneEffectType,
|
||||
TileEffectType,
|
||||
@@ -56,6 +57,8 @@ from .util import (
|
||||
LIGHT_UPDATE_INTERVAL = 10
|
||||
REQUEST_REFRESH_DELAY = 0.35
|
||||
LIFX_IDENTIFY_DELAY = 3.0
|
||||
ZONES_PER_COLOR_UPDATE_REQUEST = 8
|
||||
|
||||
RSSI_DBM_FW = AwesomeVersion("2.77")
|
||||
|
||||
|
||||
@@ -205,14 +208,53 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
methods, DEFAULT_ATTEMPTS, OVERALL_TIMEOUT
|
||||
)
|
||||
|
||||
def get_number_of_zones(self) -> int:
|
||||
"""Return the number of zones.
|
||||
|
||||
If the number of zones is not yet populated, return 1 since
|
||||
the device will have a least one zone.
|
||||
"""
|
||||
return len(self.device.color_zones) if self.device.color_zones else 1
|
||||
|
||||
@callback
|
||||
def _async_build_color_zones_update_requests(self) -> list[Callable]:
|
||||
"""Build a color zones update request."""
|
||||
device = self.device
|
||||
return [
|
||||
partial(device.get_color_zones, start_index=zone)
|
||||
for zone in range(0, len(device.color_zones), 8)
|
||||
]
|
||||
calls: list[Callable] = []
|
||||
for zone in range(
|
||||
0, self.get_number_of_zones(), ZONES_PER_COLOR_UPDATE_REQUEST
|
||||
):
|
||||
|
||||
def _wrap_get_color_zones(
|
||||
callb: Callable[[Message, dict[str, Any] | None], None],
|
||||
get_color_zones_args: dict[str, Any],
|
||||
) -> None:
|
||||
"""Capture the callback and make sure resp_set_multizonemultizone is called before."""
|
||||
|
||||
def _wrapped_callback(
|
||||
bulb: Light,
|
||||
response: Message,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
# We need to call resp_set_multizonemultizone to populate
|
||||
# the color_zones attribute before calling the callback
|
||||
device.resp_set_multizonemultizone(response)
|
||||
# Now call the original callback
|
||||
callb(bulb, response, **kwargs)
|
||||
|
||||
device.get_color_zones(**get_color_zones_args, callb=_wrapped_callback)
|
||||
|
||||
calls.append(
|
||||
partial(
|
||||
_wrap_get_color_zones,
|
||||
get_color_zones_args={
|
||||
"start_index": zone,
|
||||
"end_index": zone + ZONES_PER_COLOR_UPDATE_REQUEST - 1,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
return calls
|
||||
|
||||
async def _async_update_data(self) -> None:
|
||||
"""Fetch all device data from the api."""
|
||||
@@ -224,7 +266,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
):
|
||||
await self._async_populate_device_info()
|
||||
|
||||
num_zones = len(device.color_zones) if device.color_zones is not None else 0
|
||||
num_zones = self.get_number_of_zones()
|
||||
features = lifx_features(self.device)
|
||||
is_extended_multizone = features["extended_multizone"]
|
||||
is_legacy_multizone = not is_extended_multizone and features["multizone"]
|
||||
@@ -256,7 +298,7 @@ class LIFXUpdateCoordinator(DataUpdateCoordinator[None]):
|
||||
|
||||
if is_extended_multizone or is_legacy_multizone:
|
||||
self.active_effect = FirmwareEffect[self.device.effect.get("effect", "OFF")]
|
||||
if is_legacy_multizone and num_zones != len(device.color_zones):
|
||||
if is_legacy_multizone and num_zones != self.get_number_of_zones():
|
||||
# The number of zones has changed so we need
|
||||
# to update the zones again. This happens rarely.
|
||||
await self.async_get_color_zones()
|
||||
|
||||
@@ -382,7 +382,7 @@ class LIFXMultiZone(LIFXColor):
|
||||
"""Send a color change to the bulb."""
|
||||
bulb = self.bulb
|
||||
color_zones = bulb.color_zones
|
||||
num_zones = len(color_zones)
|
||||
num_zones = self.coordinator.get_number_of_zones()
|
||||
|
||||
# Zone brightness is not reported when powered off
|
||||
if not self.is_on and hsbk[HSBK_BRIGHTNESS] is None:
|
||||
|
||||
@@ -51,7 +51,7 @@ PLATFORMS = [
|
||||
]
|
||||
|
||||
|
||||
async def with_timeout(task, timeout_seconds=10):
|
||||
async def with_timeout(task, timeout_seconds=30):
|
||||
"""Run an async task with a timeout."""
|
||||
async with async_timeout.timeout(timeout_seconds):
|
||||
return await task
|
||||
|
||||
@@ -266,7 +266,7 @@ SENSOR_TYPES: tuple[NetatmoSensorEntityDescription, ...] = (
|
||||
netatmo_name="power",
|
||||
entity_registry_enabled_default=True,
|
||||
native_unit_of_measurement=UnitOfPower.WATT,
|
||||
state_class=SensorStateClass.TOTAL,
|
||||
state_class=SensorStateClass.MEASUREMENT,
|
||||
device_class=SensorDeviceClass.POWER,
|
||||
),
|
||||
)
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"domain": "netgear_lte",
|
||||
"name": "NETGEAR LTE",
|
||||
"codeowners": [],
|
||||
"codeowners": ["@tkdrob"],
|
||||
"documentation": "https://www.home-assistant.io/integrations/netgear_lte",
|
||||
"iot_class": "local_polling",
|
||||
"loggers": ["eternalegypt"],
|
||||
"requirements": ["eternalegypt==0.0.15"]
|
||||
"requirements": ["eternalegypt==0.0.16"]
|
||||
}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from dataclasses import dataclass, field, fields
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import timedelta
|
||||
import logging
|
||||
import traceback
|
||||
@@ -10,9 +10,16 @@ from typing import Any
|
||||
from uuid import UUID
|
||||
|
||||
from aionotion import async_get_client
|
||||
from aionotion.bridge.models import Bridge
|
||||
from aionotion.bridge.models import Bridge, BridgeAllResponse
|
||||
from aionotion.errors import InvalidCredentialsError, NotionError
|
||||
from aionotion.sensor.models import Listener, ListenerKind, Sensor
|
||||
from aionotion.sensor.models import (
|
||||
Listener,
|
||||
ListenerAllResponse,
|
||||
ListenerKind,
|
||||
Sensor,
|
||||
SensorAllResponse,
|
||||
)
|
||||
from aionotion.user.models import UserPreferences, UserPreferencesResponse
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_PASSWORD, CONF_USERNAME, Platform
|
||||
@@ -51,6 +58,11 @@ PLATFORMS = [Platform.BINARY_SENSOR, Platform.SENSOR]
|
||||
ATTR_SYSTEM_MODE = "system_mode"
|
||||
ATTR_SYSTEM_NAME = "system_name"
|
||||
|
||||
DATA_BRIDGES = "bridges"
|
||||
DATA_LISTENERS = "listeners"
|
||||
DATA_SENSORS = "sensors"
|
||||
DATA_USER_PREFERENCES = "user_preferences"
|
||||
|
||||
DEFAULT_SCAN_INTERVAL = timedelta(minutes=1)
|
||||
|
||||
CONFIG_SCHEMA = cv.removed(DOMAIN, raise_if_present=False)
|
||||
@@ -84,6 +96,9 @@ def is_uuid(value: str) -> bool:
|
||||
class NotionData:
|
||||
"""Define a manager class for Notion data."""
|
||||
|
||||
hass: HomeAssistant
|
||||
entry: ConfigEntry
|
||||
|
||||
# Define a dict of bridges, indexed by bridge ID (an integer):
|
||||
bridges: dict[int, Bridge] = field(default_factory=dict)
|
||||
|
||||
@@ -93,12 +108,40 @@ class NotionData:
|
||||
# Define a dict of sensors, indexed by sensor UUID (a string):
|
||||
sensors: dict[str, Sensor] = field(default_factory=dict)
|
||||
|
||||
# Define a user preferences response object:
|
||||
user_preferences: UserPreferences | None = field(default=None)
|
||||
|
||||
def update_data_from_response(
|
||||
self,
|
||||
response: BridgeAllResponse
|
||||
| ListenerAllResponse
|
||||
| SensorAllResponse
|
||||
| UserPreferencesResponse,
|
||||
) -> None:
|
||||
"""Update data from an aionotion response."""
|
||||
if isinstance(response, BridgeAllResponse):
|
||||
for bridge in response.bridges:
|
||||
# If a new bridge is discovered, register it:
|
||||
if bridge.id not in self.bridges:
|
||||
_async_register_new_bridge(self.hass, self.entry, bridge)
|
||||
self.bridges[bridge.id] = bridge
|
||||
elif isinstance(response, ListenerAllResponse):
|
||||
self.listeners = {listener.id: listener for listener in response.listeners}
|
||||
elif isinstance(response, SensorAllResponse):
|
||||
self.sensors = {sensor.uuid: sensor for sensor in response.sensors}
|
||||
elif isinstance(response, UserPreferencesResponse):
|
||||
self.user_preferences = response.user_preferences
|
||||
|
||||
def asdict(self) -> dict[str, Any]:
|
||||
"""Represent this dataclass (and its Pydantic contents) as a dict."""
|
||||
return {
|
||||
field.name: [obj.dict() for obj in getattr(self, field.name).values()]
|
||||
for field in fields(self)
|
||||
data: dict[str, Any] = {
|
||||
DATA_BRIDGES: [bridge.dict() for bridge in self.bridges.values()],
|
||||
DATA_LISTENERS: [listener.dict() for listener in self.listeners.values()],
|
||||
DATA_SENSORS: [sensor.dict() for sensor in self.sensors.values()],
|
||||
}
|
||||
if self.user_preferences:
|
||||
data[DATA_USER_PREFERENCES] = self.user_preferences.dict()
|
||||
return data
|
||||
|
||||
|
||||
async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
@@ -121,11 +164,12 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
async def async_update() -> NotionData:
|
||||
"""Get the latest data from the Notion API."""
|
||||
data = NotionData()
|
||||
data = NotionData(hass=hass, entry=entry)
|
||||
tasks = {
|
||||
"bridges": client.bridge.async_all(),
|
||||
"listeners": client.sensor.async_listeners(),
|
||||
"sensors": client.sensor.async_all(),
|
||||
DATA_BRIDGES: client.bridge.async_all(),
|
||||
DATA_LISTENERS: client.sensor.async_listeners(),
|
||||
DATA_SENSORS: client.sensor.async_all(),
|
||||
DATA_USER_PREFERENCES: client.user.async_preferences(),
|
||||
}
|
||||
|
||||
results = await asyncio.gather(*tasks.values(), return_exceptions=True)
|
||||
@@ -145,16 +189,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
f"There was an unknown error while updating {attr}: {result}"
|
||||
) from result
|
||||
|
||||
for item in result:
|
||||
if attr == "bridges":
|
||||
# If a new bridge is discovered, register it:
|
||||
if item.id not in data.bridges:
|
||||
_async_register_new_bridge(hass, item, entry)
|
||||
data.bridges[item.id] = item
|
||||
elif attr == "listeners":
|
||||
data.listeners[item.id] = item
|
||||
else:
|
||||
data.sensors[item.uuid] = item
|
||||
data.update_data_from_response(result)
|
||||
|
||||
return data
|
||||
|
||||
@@ -216,7 +251,7 @@ async def async_unload_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
|
||||
@callback
|
||||
def _async_register_new_bridge(
|
||||
hass: HomeAssistant, bridge: Bridge, entry: ConfigEntry
|
||||
hass: HomeAssistant, entry: ConfigEntry, bridge: Bridge
|
||||
) -> None:
|
||||
"""Register a new bridge."""
|
||||
if name := bridge.name:
|
||||
@@ -279,6 +314,11 @@ class NotionEntity(CoordinatorEntity[DataUpdateCoordinator[NotionData]]):
|
||||
and self._listener_id in self.coordinator.data.listeners
|
||||
)
|
||||
|
||||
@property
|
||||
def listener(self) -> Listener:
|
||||
"""Return the listener related to this entity."""
|
||||
return self.coordinator.data.listeners[self._listener_id]
|
||||
|
||||
@callback
|
||||
def _async_update_bridge_id(self) -> None:
|
||||
"""Update the entity's bridge ID if it has changed.
|
||||
@@ -310,21 +350,9 @@ class NotionEntity(CoordinatorEntity[DataUpdateCoordinator[NotionData]]):
|
||||
this_device.id, via_device_id=bridge_device.id
|
||||
)
|
||||
|
||||
@callback
|
||||
def _async_update_from_latest_data(self) -> None:
|
||||
"""Update the entity from the latest data."""
|
||||
raise NotImplementedError
|
||||
|
||||
@callback
|
||||
def _handle_coordinator_update(self) -> None:
|
||||
"""Respond to a DataUpdateCoordinator update."""
|
||||
if self._listener_id in self.coordinator.data.listeners:
|
||||
self._async_update_bridge_id()
|
||||
self._async_update_from_latest_data()
|
||||
|
||||
self.async_write_ha_state()
|
||||
|
||||
async def async_added_to_hass(self) -> None:
|
||||
"""Handle entity which will be added."""
|
||||
await super().async_added_to_hass()
|
||||
self._async_update_from_latest_data()
|
||||
super()._handle_coordinator_update()
|
||||
|
||||
@@ -13,7 +13,7 @@ from homeassistant.components.binary_sensor import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import EntityCategory
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import NotionEntity
|
||||
@@ -37,7 +37,7 @@ from .model import NotionEntityDescriptionMixin
|
||||
class NotionBinarySensorDescriptionMixin:
|
||||
"""Define an entity description mixin for binary and regular sensors."""
|
||||
|
||||
on_state: Literal["alarm", "critical", "leak", "not_missing", "open"]
|
||||
on_state: Literal["alarm", "leak", "low", "not_missing", "open"]
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -56,7 +56,7 @@ BINARY_SENSOR_DESCRIPTIONS = (
|
||||
device_class=BinarySensorDeviceClass.BATTERY,
|
||||
entity_category=EntityCategory.DIAGNOSTIC,
|
||||
listener_kind=ListenerKind.BATTERY,
|
||||
on_state="critical",
|
||||
on_state="low",
|
||||
),
|
||||
NotionBinarySensorDescription(
|
||||
key=SENSOR_DOOR,
|
||||
@@ -146,17 +146,10 @@ class NotionBinarySensor(NotionEntity, BinarySensorEntity):
|
||||
|
||||
entity_description: NotionBinarySensorDescription
|
||||
|
||||
@callback
|
||||
def _async_update_from_latest_data(self) -> None:
|
||||
"""Fetch new state data for the sensor."""
|
||||
listener = self.coordinator.data.listeners[self._listener_id]
|
||||
|
||||
if listener.status.trigger_value:
|
||||
state = listener.status.trigger_value
|
||||
elif listener.insights.primary.value:
|
||||
state = listener.insights.primary.value
|
||||
else:
|
||||
LOGGER.warning("Unknown listener structure: %s", listener)
|
||||
state = None
|
||||
|
||||
self._attr_is_on = self.entity_description.on_state == state
|
||||
@property
|
||||
def is_on(self) -> bool | None:
|
||||
"""Return true if the binary sensor is on."""
|
||||
if not self.listener.insights.primary.value:
|
||||
LOGGER.warning("Unknown listener structure: %s", self.listener.dict())
|
||||
return False
|
||||
return self.listener.insights.primary.value == self.entity_description.on_state
|
||||
|
||||
@@ -16,6 +16,7 @@ CONF_DEVICE_KEY = "device_key"
|
||||
CONF_HARDWARE_ID = "hardware_id"
|
||||
CONF_LAST_BRIDGE_HARDWARE_ID = "last_bridge_hardware_id"
|
||||
CONF_TITLE = "title"
|
||||
CONF_USER_ID = "user_id"
|
||||
|
||||
TO_REDACT = {
|
||||
CONF_DEVICE_KEY,
|
||||
@@ -27,6 +28,7 @@ TO_REDACT = {
|
||||
CONF_TITLE,
|
||||
CONF_UNIQUE_ID,
|
||||
CONF_USERNAME,
|
||||
CONF_USER_ID,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aionotion"],
|
||||
"requirements": ["aionotion==2023.04.2"]
|
||||
"requirements": ["aionotion==2023.05.4"]
|
||||
}
|
||||
|
||||
@@ -11,11 +11,11 @@ from homeassistant.components.sensor import (
|
||||
)
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import UnitOfTemperature
|
||||
from homeassistant.core import HomeAssistant, callback
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.helpers.entity_platform import AddEntitiesCallback
|
||||
|
||||
from . import NotionEntity
|
||||
from .const import DOMAIN, LOGGER, SENSOR_TEMPERATURE
|
||||
from .const import DOMAIN, SENSOR_TEMPERATURE
|
||||
from .model import NotionEntityDescriptionMixin
|
||||
|
||||
|
||||
@@ -63,15 +63,24 @@ async def async_setup_entry(
|
||||
class NotionSensor(NotionEntity, SensorEntity):
|
||||
"""Define a Notion sensor."""
|
||||
|
||||
@callback
|
||||
def _async_update_from_latest_data(self) -> None:
|
||||
"""Fetch new state data for the sensor."""
|
||||
listener = self.coordinator.data.listeners[self._listener_id]
|
||||
@property
|
||||
def native_unit_of_measurement(self) -> str | None:
|
||||
"""Return the unit of measurement of the sensor."""
|
||||
if self.listener.listener_kind == ListenerKind.TEMPERATURE:
|
||||
if not self.coordinator.data.user_preferences:
|
||||
return None
|
||||
if self.coordinator.data.user_preferences.celsius_enabled:
|
||||
return UnitOfTemperature.CELSIUS
|
||||
return UnitOfTemperature.FAHRENHEIT
|
||||
return None
|
||||
|
||||
if listener.listener_kind == ListenerKind.TEMPERATURE:
|
||||
self._attr_native_value = round(listener.status.temperature, 1) # type: ignore[attr-defined]
|
||||
else:
|
||||
LOGGER.error(
|
||||
"Unknown listener type for sensor %s",
|
||||
self.coordinator.data.sensors[self._sensor_id],
|
||||
)
|
||||
@property
|
||||
def native_value(self) -> str | None:
|
||||
"""Return the value reported by the sensor.
|
||||
|
||||
The Notion API only returns a localized string for temperature (e.g. "70°"); we
|
||||
simply remove the degree symbol:
|
||||
"""
|
||||
if not self.listener.status_localized:
|
||||
return None
|
||||
return self.listener.status_localized.state[:-1]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
"""The ONVIF integration."""
|
||||
import asyncio
|
||||
from http import HTTPStatus
|
||||
import logging
|
||||
|
||||
from httpx import RequestError
|
||||
@@ -56,7 +57,20 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
except ONVIFError as err:
|
||||
await device.device.close()
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not setup camera {device.device.host}:{device.device.port}: {err}"
|
||||
f"Could not setup camera {device.device.host}:{device.device.port}: {stringify_onvif_error(err)}"
|
||||
) from err
|
||||
except TransportError as err:
|
||||
await device.device.close()
|
||||
stringified_onvif_error = stringify_onvif_error(err)
|
||||
if err.status_code in (
|
||||
HTTPStatus.UNAUTHORIZED.value,
|
||||
HTTPStatus.FORBIDDEN.value,
|
||||
):
|
||||
raise ConfigEntryAuthFailed(
|
||||
f"Auth Failed: {stringified_onvif_error}"
|
||||
) from err
|
||||
raise ConfigEntryNotReady(
|
||||
f"Could not setup camera {device.device.host}:{device.device.port}: {stringified_onvif_error}"
|
||||
) from err
|
||||
except asyncio.CancelledError as err:
|
||||
# After https://github.com/agronholm/anyio/issues/374 is resolved
|
||||
|
||||
@@ -34,7 +34,7 @@ class RebootButton(ONVIFBaseEntity, ButtonEntity):
|
||||
|
||||
async def async_press(self) -> None:
|
||||
"""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()
|
||||
|
||||
|
||||
|
||||
@@ -142,10 +142,14 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
hass.async_create_task(hass.config_entries.async_reload(entry_id))
|
||||
return self.async_abort(reason="reauth_successful")
|
||||
|
||||
username = (user_input or {}).get(CONF_USERNAME) or entry.data[CONF_USERNAME]
|
||||
return self.async_show_form(
|
||||
step_id="reauth_confirm",
|
||||
data_schema=vol.Schema(
|
||||
{vol.Required(CONF_USERNAME): str, vol.Required(CONF_PASSWORD): str}
|
||||
{
|
||||
vol.Required(CONF_USERNAME, default=username): str,
|
||||
vol.Required(CONF_PASSWORD): str,
|
||||
}
|
||||
),
|
||||
errors=errors,
|
||||
description_placeholders=description_placeholders,
|
||||
@@ -275,7 +279,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
|
||||
try:
|
||||
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
|
||||
if not self.device_id:
|
||||
try:
|
||||
@@ -314,7 +318,7 @@ class OnvifFlowHandler(config_entries.ConfigFlow, domain=DOMAIN):
|
||||
}
|
||||
)
|
||||
# Verify there is an H264 profile
|
||||
media_service = device.create_media_service()
|
||||
media_service = await device.create_media_service()
|
||||
profiles = await media_service.GetProfiles()
|
||||
except AttributeError: # Likely an empty document or 404 from the wrong port
|
||||
LOGGER.debug(
|
||||
|
||||
@@ -12,7 +12,7 @@ from httpx import RequestError
|
||||
import onvif
|
||||
from onvif import ONVIFCamera
|
||||
from onvif.exceptions import ONVIFError
|
||||
from zeep.exceptions import Fault, XMLParseError
|
||||
from zeep.exceptions import Fault, TransportError, XMLParseError, XMLSyntaxError
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import (
|
||||
@@ -136,7 +136,7 @@ class ONVIFDevice:
|
||||
|
||||
if self.capabilities.ptz:
|
||||
LOGGER.debug("%s: creating PTZ service", self.name)
|
||||
self.device.create_ptz_service()
|
||||
await self.device.create_ptz_service()
|
||||
|
||||
# Determine max resolution from profiles
|
||||
self.max_resolution = max(
|
||||
@@ -159,7 +159,7 @@ class ONVIFDevice:
|
||||
|
||||
async def async_manually_set_date_and_time(self) -> None:
|
||||
"""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
|
||||
device_time = await device_mgmt.GetSystemDateAndTime()
|
||||
@@ -202,82 +202,105 @@ class ONVIFDevice:
|
||||
async def async_check_date_and_time(self) -> None:
|
||||
"""Warns if device and system date not synced."""
|
||||
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()
|
||||
|
||||
LOGGER.debug("%s: Retrieving current device date/time", self.name)
|
||||
try:
|
||||
system_date = dt_util.utcnow()
|
||||
device_time = await device_mgmt.GetSystemDateAndTime()
|
||||
if not device_time:
|
||||
LOGGER.debug(
|
||||
"""Couldn't get device '%s' date/time.
|
||||
GetSystemDateAndTime() return null/empty""",
|
||||
self.name,
|
||||
)
|
||||
return
|
||||
|
||||
LOGGER.debug("%s: Device time: %s", self.name, device_time)
|
||||
|
||||
tzone = dt_util.DEFAULT_TIME_ZONE
|
||||
cdate = device_time.LocalDateTime
|
||||
if device_time.UTCDateTime:
|
||||
tzone = dt_util.UTC
|
||||
cdate = device_time.UTCDateTime
|
||||
elif device_time.TimeZone:
|
||||
tzone = dt_util.get_time_zone(device_time.TimeZone.TZ) or tzone
|
||||
|
||||
if cdate is None:
|
||||
LOGGER.warning(
|
||||
"%s: Could not retrieve date/time on this camera", self.name
|
||||
)
|
||||
else:
|
||||
cam_date = dt.datetime(
|
||||
cdate.Date.Year,
|
||||
cdate.Date.Month,
|
||||
cdate.Date.Day,
|
||||
cdate.Time.Hour,
|
||||
cdate.Time.Minute,
|
||||
cdate.Time.Second,
|
||||
0,
|
||||
tzone,
|
||||
)
|
||||
|
||||
cam_date_utc = cam_date.astimezone(dt_util.UTC)
|
||||
|
||||
LOGGER.debug(
|
||||
"%s: Device date/time: %s | System date/time: %s",
|
||||
self.name,
|
||||
cam_date_utc,
|
||||
system_date,
|
||||
)
|
||||
|
||||
dt_diff = cam_date - system_date
|
||||
self._dt_diff_seconds = dt_diff.total_seconds()
|
||||
|
||||
# It could be off either direction, so we need to check the absolute value
|
||||
if abs(self._dt_diff_seconds) > 5:
|
||||
LOGGER.warning(
|
||||
(
|
||||
"The date/time on %s (UTC) is '%s', "
|
||||
"which is different from the system '%s', "
|
||||
"this could lead to authentication issues"
|
||||
),
|
||||
self.name,
|
||||
cam_date_utc,
|
||||
system_date,
|
||||
)
|
||||
if device_time.DateTimeType == "Manual":
|
||||
# Set Date and Time ourselves if Date and Time is set manually in the camera.
|
||||
await self.async_manually_set_date_and_time()
|
||||
except RequestError as err:
|
||||
LOGGER.warning(
|
||||
"Couldn't get device '%s' date/time. Error: %s", self.name, err
|
||||
)
|
||||
return
|
||||
|
||||
if not device_time:
|
||||
LOGGER.debug(
|
||||
"""Couldn't get device '%s' date/time.
|
||||
GetSystemDateAndTime() return null/empty""",
|
||||
self.name,
|
||||
)
|
||||
return
|
||||
|
||||
LOGGER.debug("%s: Device time: %s", self.name, device_time)
|
||||
|
||||
tzone = dt_util.DEFAULT_TIME_ZONE
|
||||
cdate = device_time.LocalDateTime
|
||||
if device_time.UTCDateTime:
|
||||
tzone = dt_util.UTC
|
||||
cdate = device_time.UTCDateTime
|
||||
elif device_time.TimeZone:
|
||||
tzone = dt_util.get_time_zone(device_time.TimeZone.TZ) or tzone
|
||||
|
||||
if cdate is None:
|
||||
LOGGER.warning("%s: Could not retrieve date/time on this camera", self.name)
|
||||
return
|
||||
|
||||
cam_date = dt.datetime(
|
||||
cdate.Date.Year,
|
||||
cdate.Date.Month,
|
||||
cdate.Date.Day,
|
||||
cdate.Time.Hour,
|
||||
cdate.Time.Minute,
|
||||
cdate.Time.Second,
|
||||
0,
|
||||
tzone,
|
||||
)
|
||||
|
||||
cam_date_utc = cam_date.astimezone(dt_util.UTC)
|
||||
|
||||
LOGGER.debug(
|
||||
"%s: Device date/time: %s | System date/time: %s",
|
||||
self.name,
|
||||
cam_date_utc,
|
||||
system_date,
|
||||
)
|
||||
|
||||
dt_diff = cam_date - system_date
|
||||
self._dt_diff_seconds = dt_diff.total_seconds()
|
||||
|
||||
# It could be off either direction, so we need to check the absolute value
|
||||
if abs(self._dt_diff_seconds) < 5:
|
||||
return
|
||||
|
||||
LOGGER.warning(
|
||||
(
|
||||
"The date/time on %s (UTC) is '%s', "
|
||||
"which is different from the system '%s', "
|
||||
"this could lead to authentication issues"
|
||||
),
|
||||
self.name,
|
||||
cam_date_utc,
|
||||
system_date,
|
||||
)
|
||||
|
||||
if device_time.DateTimeType != "Manual":
|
||||
return
|
||||
|
||||
# Set Date and Time ourselves if Date and Time is set manually in the camera.
|
||||
try:
|
||||
await self.async_manually_set_date_and_time()
|
||||
except (RequestError, TransportError):
|
||||
LOGGER.warning("%s: Could not sync date/time on this camera", self.name)
|
||||
|
||||
async def async_get_device_info(self) -> DeviceInfo:
|
||||
"""Obtain information about this device."""
|
||||
device_mgmt = self.device.create_devicemgmt_service()
|
||||
device_info = await device_mgmt.GetDeviceInformation()
|
||||
device_mgmt = await self.device.create_devicemgmt_service()
|
||||
manufacturer = None
|
||||
model = None
|
||||
firmware_version = None
|
||||
serial_number = None
|
||||
try:
|
||||
device_info = await device_mgmt.GetDeviceInformation()
|
||||
except (XMLParseError, XMLSyntaxError, TransportError) as ex:
|
||||
# Some cameras have invalid UTF-8 in their device information (TransportError)
|
||||
# and others have completely invalid XML (XMLParseError, XMLSyntaxError)
|
||||
LOGGER.warning("%s: Failed to fetch device information: %s", self.name, ex)
|
||||
else:
|
||||
manufacturer = device_info.Manufacturer
|
||||
model = device_info.Model
|
||||
firmware_version = device_info.FirmwareVersion
|
||||
serial_number = device_info.SerialNumber
|
||||
|
||||
# Grab the last MAC address for backwards compatibility
|
||||
mac = None
|
||||
@@ -297,10 +320,10 @@ class ONVIFDevice:
|
||||
)
|
||||
|
||||
return DeviceInfo(
|
||||
device_info.Manufacturer,
|
||||
device_info.Model,
|
||||
device_info.FirmwareVersion,
|
||||
device_info.SerialNumber,
|
||||
manufacturer,
|
||||
model,
|
||||
firmware_version,
|
||||
serial_number,
|
||||
mac,
|
||||
)
|
||||
|
||||
@@ -308,7 +331,7 @@ class ONVIFDevice:
|
||||
"""Obtain information about the available services on the device."""
|
||||
snapshot = False
|
||||
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()
|
||||
snapshot = media_capabilities and media_capabilities.SnapshotUri
|
||||
|
||||
@@ -319,7 +342,7 @@ class ONVIFDevice:
|
||||
|
||||
imaging = False
|
||||
with suppress(*GET_CAPABILITIES_EXCEPTIONS):
|
||||
self.device.create_imaging_service()
|
||||
await self.device.create_imaging_service()
|
||||
imaging = True
|
||||
|
||||
return Capabilities(snapshot=snapshot, ptz=ptz, imaging=imaging)
|
||||
@@ -328,7 +351,7 @@ class ONVIFDevice:
|
||||
"""Start the event handler."""
|
||||
with suppress(*GET_CAPABILITIES_EXCEPTIONS, XMLParseError):
|
||||
onvif_capabilities = self.onvif_capabilities or {}
|
||||
pull_point_support = onvif_capabilities.get("Events", {}).get(
|
||||
pull_point_support = (onvif_capabilities.get("Events") or {}).get(
|
||||
"WSPullPointSupport"
|
||||
)
|
||||
LOGGER.debug("%s: WSPullPointSupport: %s", self.name, pull_point_support)
|
||||
@@ -338,7 +361,7 @@ class ONVIFDevice:
|
||||
|
||||
async def async_get_profiles(self) -> list[Profile]:
|
||||
"""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)
|
||||
try:
|
||||
result = await media_service.GetProfiles()
|
||||
@@ -385,7 +408,7 @@ class ONVIFDevice:
|
||||
)
|
||||
|
||||
try:
|
||||
ptz_service = self.device.create_ptz_service()
|
||||
ptz_service = await self.device.create_ptz_service()
|
||||
presets = await ptz_service.GetPresets(profile.token)
|
||||
profile.ptz.presets = [preset.token for preset in presets if preset]
|
||||
except GET_CAPABILITIES_EXCEPTIONS:
|
||||
@@ -404,7 +427,7 @@ class ONVIFDevice:
|
||||
|
||||
async def async_get_stream_uri(self, profile: Profile) -> str:
|
||||
"""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.ProfileToken = profile.token
|
||||
req.StreamSetup = {
|
||||
@@ -431,7 +454,7 @@ class ONVIFDevice:
|
||||
LOGGER.warning("PTZ actions are not supported on device '%s'", self.name)
|
||||
return
|
||||
|
||||
ptz_service = self.device.create_ptz_service()
|
||||
ptz_service = await self.device.create_ptz_service()
|
||||
|
||||
pan_val = distance * PAN_FACTOR.get(pan, 0)
|
||||
tilt_val = distance * TILT_FACTOR.get(tilt, 0)
|
||||
@@ -553,7 +576,7 @@ class ONVIFDevice:
|
||||
LOGGER.warning("PTZ actions are not supported on device '%s'", self.name)
|
||||
return
|
||||
|
||||
ptz_service = self.device.create_ptz_service()
|
||||
ptz_service = await self.device.create_ptz_service()
|
||||
|
||||
LOGGER.debug(
|
||||
"Running Aux Command | Cmd = %s",
|
||||
@@ -584,7 +607,7 @@ class ONVIFDevice:
|
||||
)
|
||||
return
|
||||
|
||||
imaging_service = self.device.create_imaging_service()
|
||||
imaging_service = await self.device.create_imaging_service()
|
||||
|
||||
LOGGER.debug("Setting Imaging Setting | Settings = %s", settings)
|
||||
try:
|
||||
|
||||
@@ -27,6 +27,10 @@ async def async_get_config_entry_diagnostics(
|
||||
"info": asdict(device.info),
|
||||
"capabilities": asdict(device.capabilities),
|
||||
"profiles": [asdict(profile) for profile in device.profiles],
|
||||
"services": {
|
||||
str(key): service.url for key, service in device.device.services.items()
|
||||
},
|
||||
"xaddrs": device.device.xaddrs,
|
||||
}
|
||||
data["events"] = {
|
||||
"webhook_manager_state": device.events.webhook_manager.state,
|
||||
|
||||
@@ -9,7 +9,7 @@ import datetime as dt
|
||||
from aiohttp.web import Request
|
||||
from httpx import RemoteProtocolError, RequestError, TransportError
|
||||
from onvif import ONVIFCamera, ONVIFService
|
||||
from onvif.client import NotificationManager
|
||||
from onvif.client import NotificationManager, retry_connection_error
|
||||
from onvif.exceptions import ONVIFError
|
||||
from zeep.exceptions import Fault, ValidationError, XMLParseError
|
||||
|
||||
@@ -40,8 +40,8 @@ SET_SYNCHRONIZATION_POINT_ERRORS = (*SUBSCRIPTION_ERRORS, TypeError)
|
||||
UNSUBSCRIBE_ERRORS = (XMLParseError, *SUBSCRIPTION_ERRORS)
|
||||
RENEW_ERRORS = (ONVIFError, RequestError, XMLParseError, *SUBSCRIPTION_ERRORS)
|
||||
#
|
||||
# We only keep the subscription alive for 3 minutes, and will keep
|
||||
# renewing it every 1.5 minutes. This is to avoid the camera
|
||||
# We only keep the subscription alive for 10 minutes, and will keep
|
||||
# renewing it every 8 minutes. This is to avoid the camera
|
||||
# accumulating subscriptions which will be impossible to clean up
|
||||
# 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
|
||||
# the subscriptions expire or the camera is rebooted.
|
||||
#
|
||||
SUBSCRIPTION_TIME = dt.timedelta(minutes=3)
|
||||
SUBSCRIPTION_RELATIVE_TIME = (
|
||||
"PT3M" # use relative time since the time on the camera is not reliable
|
||||
)
|
||||
SUBSCRIPTION_RENEW_INTERVAL = SUBSCRIPTION_TIME.total_seconds() / 2
|
||||
SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR = 60.0
|
||||
SUBSCRIPTION_TIME = dt.timedelta(minutes=10)
|
||||
|
||||
# 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
|
||||
# parse time in the format "PT10M" (10 minutes).
|
||||
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_MESSAGE_LIMIT = 100
|
||||
@@ -276,7 +289,13 @@ class PullPointManager:
|
||||
"""Pause pullpoint subscription."""
|
||||
LOGGER.debug("%s: Pausing PullPoint manager", self._name)
|
||||
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
|
||||
def async_resume(self) -> None:
|
||||
@@ -327,20 +346,7 @@ class PullPointManager:
|
||||
async def _async_start_pullpoint(self) -> bool:
|
||||
"""Start pullpoint subscription."""
|
||||
try:
|
||||
try:
|
||||
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()
|
||||
started = await self._async_create_pullpoint_subscription()
|
||||
except CREATE_ERRORS as err:
|
||||
LOGGER.debug(
|
||||
"%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.
|
||||
return
|
||||
async with self._renew_lock:
|
||||
next_attempt = SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR
|
||||
next_attempt = SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR
|
||||
try:
|
||||
if (
|
||||
await self._async_renew_pullpoint()
|
||||
or await self._async_restart_pullpoint()
|
||||
):
|
||||
if await self._async_renew_pullpoint():
|
||||
next_attempt = SUBSCRIPTION_RENEW_INTERVAL
|
||||
else:
|
||||
await self._async_restart_pullpoint()
|
||||
finally:
|
||||
self.async_schedule_pullpoint_renew(next_attempt)
|
||||
|
||||
@retry_connection_error(SUBSCRIPTION_ATTEMPTS)
|
||||
async def _async_create_pullpoint_subscription(self) -> bool:
|
||||
"""Create pullpoint subscription."""
|
||||
|
||||
@@ -392,12 +398,12 @@ class PullPointManager:
|
||||
return False
|
||||
|
||||
# Create subscription manager
|
||||
self._pullpoint_subscription = self._device.create_subscription_service(
|
||||
self._pullpoint_subscription = await self._device.create_subscription_service(
|
||||
"PullPointSubscription"
|
||||
)
|
||||
|
||||
# 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
|
||||
with suppress(*SET_SYNCHRONIZATION_POINT_ERRORS):
|
||||
@@ -447,6 +453,11 @@ class PullPointManager:
|
||||
)
|
||||
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:
|
||||
"""Renew the PullPoint subscription."""
|
||||
if (
|
||||
@@ -458,20 +469,7 @@ class PullPointManager:
|
||||
# The first time we renew, we may get a Fault error so we
|
||||
# suppress it. The subscription will be restarted in
|
||||
# async_restart later.
|
||||
try:
|
||||
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)
|
||||
await self._async_call_pullpoint_subscription_renew()
|
||||
LOGGER.debug("%s: Renewed PullPoint subscription", self._name)
|
||||
return True
|
||||
except RENEW_ERRORS as err:
|
||||
@@ -521,7 +519,7 @@ class PullPointManager:
|
||||
stringify_onvif_error(err),
|
||||
)
|
||||
return True
|
||||
except (XMLParseError, *SUBSCRIPTION_ERRORS) as err:
|
||||
except Fault as err:
|
||||
# Device may not support subscriptions so log at debug level
|
||||
# when we get an XMLParseError
|
||||
LOGGER.debug(
|
||||
@@ -532,6 +530,16 @@ class PullPointManager:
|
||||
# Treat errors as if the camera restarted. Assume that the pullpoint
|
||||
# subscription is no longer valid.
|
||||
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 the webhook became started working during the long poll,
|
||||
@@ -655,6 +663,7 @@ class WebHookManager:
|
||||
self._renew_or_restart_job,
|
||||
)
|
||||
|
||||
@retry_connection_error(SUBSCRIPTION_ATTEMPTS)
|
||||
async def _async_create_webhook_subscription(self) -> None:
|
||||
"""Create webhook subscription."""
|
||||
LOGGER.debug(
|
||||
@@ -689,20 +698,7 @@ class WebHookManager:
|
||||
async def _async_start_webhook(self) -> bool:
|
||||
"""Start webhook."""
|
||||
try:
|
||||
try:
|
||||
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()
|
||||
await self._async_create_webhook_subscription()
|
||||
except CREATE_ERRORS as err:
|
||||
self._event_manager.async_webhook_failed()
|
||||
LOGGER.debug(
|
||||
@@ -720,6 +716,12 @@ class WebHookManager:
|
||||
await self._async_unsubscribe_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:
|
||||
"""Renew webhook subscription."""
|
||||
if (
|
||||
@@ -728,20 +730,7 @@ class WebHookManager:
|
||||
):
|
||||
return False
|
||||
try:
|
||||
try:
|
||||
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)
|
||||
await self._async_call_webhook_subscription_renew()
|
||||
LOGGER.debug("%s: Renewed Webhook subscription", self._name)
|
||||
return True
|
||||
except RENEW_ERRORS as err:
|
||||
@@ -765,13 +754,12 @@ class WebHookManager:
|
||||
# scheduled when the current one is done if needed.
|
||||
return
|
||||
async with self._renew_lock:
|
||||
next_attempt = SUBSCRIPTION_RENEW_INTERVAL_ON_ERROR
|
||||
next_attempt = SUBSCRIPTION_RESTART_INTERVAL_ON_ERROR
|
||||
try:
|
||||
if (
|
||||
await self._async_renew_webhook()
|
||||
or await self._async_restart_webhook()
|
||||
):
|
||||
if await self._async_renew_webhook():
|
||||
next_attempt = SUBSCRIPTION_RENEW_INTERVAL
|
||||
else:
|
||||
await self._async_restart_webhook()
|
||||
finally:
|
||||
self._async_schedule_webhook_renew(next_attempt)
|
||||
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/onvif",
|
||||
"iot_class": "local_push",
|
||||
"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]]
|
||||
] = 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:
|
||||
"""Convert strings to datetimes, if invalid, return None."""
|
||||
@@ -188,7 +201,7 @@ async def async_parse_field_detector(uid: str, msg) -> Event | None:
|
||||
rule = ""
|
||||
for source in msg.Message._value_1.Source.SimpleItem:
|
||||
if source.Name == "VideoSourceConfigurationToken":
|
||||
video_source = source.Value
|
||||
video_source = _normalize_video_source(source.Value)
|
||||
if source.Name == "VideoAnalyticsConfigurationToken":
|
||||
video_analytics = source.Value
|
||||
if source.Name == "Rule":
|
||||
@@ -220,7 +233,7 @@ async def async_parse_cell_motion_detector(uid: str, msg) -> Event | None:
|
||||
rule = ""
|
||||
for source in msg.Message._value_1.Source.SimpleItem:
|
||||
if source.Name == "VideoSourceConfigurationToken":
|
||||
video_source = source.Value
|
||||
video_source = _normalize_video_source(source.Value)
|
||||
if source.Name == "VideoAnalyticsConfigurationToken":
|
||||
video_analytics = source.Value
|
||||
if source.Name == "Rule":
|
||||
@@ -251,7 +264,7 @@ async def async_parse_motion_region_detector(uid: str, msg) -> Event | None:
|
||||
rule = ""
|
||||
for source in msg.Message._value_1.Source.SimpleItem:
|
||||
if source.Name == "VideoSourceConfigurationToken":
|
||||
video_source = source.Value
|
||||
video_source = _normalize_video_source(source.Value)
|
||||
if source.Name == "VideoAnalyticsConfigurationToken":
|
||||
video_analytics = source.Value
|
||||
if source.Name == "Rule":
|
||||
@@ -282,7 +295,7 @@ async def async_parse_tamper_detector(uid: str, msg) -> Event | None:
|
||||
rule = ""
|
||||
for source in msg.Message._value_1.Source.SimpleItem:
|
||||
if source.Name == "VideoSourceConfigurationToken":
|
||||
video_source = source.Value
|
||||
video_source = _normalize_video_source(source.Value)
|
||||
if source.Name == "VideoAnalyticsConfigurationToken":
|
||||
video_analytics = source.Value
|
||||
if source.Name == "Rule":
|
||||
@@ -312,7 +325,7 @@ async def async_parse_dog_cat_detector(uid: str, msg) -> Event | None:
|
||||
video_source = ""
|
||||
for source in msg.Message._value_1.Source.SimpleItem:
|
||||
if source.Name == "Source":
|
||||
video_source = source.Value
|
||||
video_source = _normalize_video_source(source.Value)
|
||||
|
||||
return Event(
|
||||
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 = ""
|
||||
for source in msg.Message._value_1.Source.SimpleItem:
|
||||
if source.Name == "Source":
|
||||
video_source = source.Value
|
||||
video_source = _normalize_video_source(source.Value)
|
||||
|
||||
return Event(
|
||||
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 = ""
|
||||
for source in msg.Message._value_1.Source.SimpleItem:
|
||||
if source.Name == "Source":
|
||||
video_source = source.Value
|
||||
video_source = _normalize_video_source(source.Value)
|
||||
|
||||
return Event(
|
||||
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 = ""
|
||||
for source in msg.Message._value_1.Source.SimpleItem:
|
||||
if source.Name == "Source":
|
||||
video_source = source.Value
|
||||
video_source = _normalize_video_source(source.Value)
|
||||
|
||||
return Event(
|
||||
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
|
||||
|
||||
|
||||
@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")
|
||||
# pylint: disable=protected-access
|
||||
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 = ""
|
||||
for source in msg.Message._value_1.Source.SimpleItem:
|
||||
if source.Name == "VideoSourceConfigurationToken":
|
||||
video_source = source.Value
|
||||
video_source = _normalize_video_source(source.Value)
|
||||
if source.Name == "VideoAnalyticsConfigurationToken":
|
||||
video_analytics = source.Value
|
||||
if source.Name == "Rule":
|
||||
|
||||
@@ -47,6 +47,7 @@
|
||||
},
|
||||
"reauth_confirm": {
|
||||
"title": "Reauthenticate the ONVIF device",
|
||||
"description": "Some devices will reject authentication if the time is out of sync by more than 5 seconds. If authentication is unsuccessful, verify the time on the device is correct and try again.",
|
||||
"data": {
|
||||
"username": "[%key:common::config_flow::data::username%]",
|
||||
"password": "[%key:common::config_flow::data::password%]"
|
||||
|
||||
@@ -34,7 +34,7 @@ def stringify_onvif_error(error: Exception) -> str:
|
||||
message += f" (actor:{error.actor})"
|
||||
else:
|
||||
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:
|
||||
|
||||
@@ -38,7 +38,8 @@ DEFAULT_ALTITUDE = 0
|
||||
|
||||
EVENT_OPENSKY_ENTRY = f"{DOMAIN}_entry"
|
||||
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_FIELDS = [
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
"integration_type": "hub",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["boto3", "botocore", "pyhumps", "pyoverkiz", "s3transfer"],
|
||||
"requirements": ["pyoverkiz==1.7.7"],
|
||||
"requirements": ["pyoverkiz==1.7.8"],
|
||||
"zeroconf": [
|
||||
{
|
||||
"type": "_kizbox._tcp.local.",
|
||||
|
||||
@@ -7,5 +7,5 @@
|
||||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["vehicle==1.0.0"]
|
||||
"requirements": ["vehicle==1.0.1"]
|
||||
}
|
||||
|
||||
@@ -46,28 +46,28 @@ BUTTON_ENTITIES = (
|
||||
key="ptz_left",
|
||||
name="PTZ left",
|
||||
icon="mdi:pan",
|
||||
supported=lambda api, ch: api.supported(ch, "pan_tilt"),
|
||||
supported=lambda api, ch: api.supported(ch, "pan"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.left.value),
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_right",
|
||||
name="PTZ right",
|
||||
icon="mdi:pan",
|
||||
supported=lambda api, ch: api.supported(ch, "pan_tilt"),
|
||||
supported=lambda api, ch: api.supported(ch, "pan"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.right.value),
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_up",
|
||||
name="PTZ up",
|
||||
icon="mdi:pan",
|
||||
supported=lambda api, ch: api.supported(ch, "pan_tilt"),
|
||||
supported=lambda api, ch: api.supported(ch, "tilt"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.up.value),
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
key="ptz_down",
|
||||
name="PTZ down",
|
||||
icon="mdi:pan",
|
||||
supported=lambda api, ch: api.supported(ch, "pan_tilt"),
|
||||
supported=lambda api, ch: api.supported(ch, "tilt"),
|
||||
method=lambda api, ch: api.set_ptz_command(ch, command=PtzEnum.down.value),
|
||||
),
|
||||
ReolinkButtonEntityDescription(
|
||||
|
||||
@@ -18,5 +18,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/reolink",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["reolink_aio"],
|
||||
"requirements": ["reolink-aio==0.5.13"]
|
||||
"requirements": ["reolink-aio==0.5.15"]
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ SIREN_ENTITIES = (
|
||||
key="siren",
|
||||
name="Siren",
|
||||
icon="mdi:alarm-light",
|
||||
supported=lambda api, ch: api.supported(ch, "siren"),
|
||||
supported=lambda api, ch: api.supported(ch, "siren_play"),
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import logging
|
||||
from roborock.api import RoborockApiClient
|
||||
from roborock.cloud_api import RoborockMqttClient
|
||||
from roborock.containers import HomeDataDevice, RoborockDeviceInfo, UserData
|
||||
from roborock.exceptions import RoborockException
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry
|
||||
from homeassistant.const import CONF_USERNAME
|
||||
@@ -44,7 +45,10 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
for device, result in zip(devices, network_results)
|
||||
if result is not None
|
||||
}
|
||||
await mqtt_client.async_disconnect()
|
||||
try:
|
||||
await mqtt_client.async_disconnect()
|
||||
except RoborockException as err:
|
||||
_LOGGER.warning("Failed disconnecting from the mqtt server %s", err)
|
||||
if not network_info:
|
||||
raise ConfigEntryNotReady(
|
||||
"Could not get network information about your devices"
|
||||
|
||||
@@ -29,7 +29,7 @@ apply:
|
||||
name: Entities state
|
||||
description: The entities and the state that they need to be.
|
||||
required: true
|
||||
example:
|
||||
example: |
|
||||
light.kitchen: "on"
|
||||
light.ceiling:
|
||||
state: "on"
|
||||
@@ -60,7 +60,7 @@ create:
|
||||
entities:
|
||||
name: Entities state
|
||||
description: The entities to control with the scene.
|
||||
example:
|
||||
example: |
|
||||
light.tv_back_light: "on"
|
||||
light.ceiling:
|
||||
state: "on"
|
||||
@@ -70,7 +70,7 @@ create:
|
||||
snapshot_entities:
|
||||
name: Snapshot entities
|
||||
description: The entities of which a snapshot is to be taken
|
||||
example:
|
||||
example: |
|
||||
- light.ceiling
|
||||
- light.kitchen
|
||||
selector:
|
||||
|
||||
@@ -17,7 +17,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool:
|
||||
hass.data[DOMAIN][entry.entry_id] = hub
|
||||
try:
|
||||
if hub.sia_client:
|
||||
await hub.sia_client.start(reuse_port=True)
|
||||
await hub.sia_client.async_start(reuse_port=True)
|
||||
except OSError as exc:
|
||||
raise ConfigEntryNotReady(
|
||||
f"SIA Server at port {entry.data[CONF_PORT]} could not start."
|
||||
|
||||
@@ -123,7 +123,7 @@ class SIAAlarmControlPanel(SIABaseEntity, AlarmControlPanelEntity):
|
||||
"""
|
||||
new_state = None
|
||||
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:
|
||||
return False
|
||||
_LOGGER.debug("New state will be %s", new_state)
|
||||
|
||||
@@ -132,7 +132,7 @@ class SIABinarySensor(SIABaseEntity, BinarySensorEntity):
|
||||
"""
|
||||
new_state = None
|
||||
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:
|
||||
return False
|
||||
_LOGGER.debug("New state will be %s", new_state)
|
||||
|
||||
@@ -71,7 +71,7 @@ class SIAHub:
|
||||
async def async_shutdown(self, _: Event | None = None) -> None:
|
||||
"""Shutdown the SIA server."""
|
||||
if self.sia_client:
|
||||
await self.sia_client.stop()
|
||||
await self.sia_client.async_stop()
|
||||
|
||||
async def async_create_and_fire_event(self, event: SIAEvent) -> None:
|
||||
"""Create a event on HA dispatcher and then on HA's bus, with the data from the SIAEvent.
|
||||
|
||||
@@ -11,5 +11,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/sleepiq",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["asyncsleepiq"],
|
||||
"requirements": ["asyncsleepiq==1.3.4"]
|
||||
"requirements": ["asyncsleepiq==1.3.5"]
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/sonos",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["soco"],
|
||||
"requirements": ["soco==0.29.1", "sonos-websocket==0.1.0"],
|
||||
"requirements": ["soco==0.29.1", "sonos-websocket==0.1.1"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:schemas-upnp-org:device:ZonePlayer:1"
|
||||
|
||||
@@ -147,8 +147,10 @@ async def async_remove_config_entry_device(
|
||||
api = data.api
|
||||
serial = api.information.serial
|
||||
storage = api.storage
|
||||
# get_all_cameras does not do I/O
|
||||
all_cameras: list[SynoCamera] = api.surveillance_station.get_all_cameras()
|
||||
all_cameras: list[SynoCamera] = []
|
||||
if api.surveillance_station is not None:
|
||||
# get_all_cameras does not do I/O
|
||||
all_cameras = api.surveillance_station.get_all_cameras()
|
||||
device_ids = chain(
|
||||
(camera.id for camera in all_cameras),
|
||||
storage.volumes_ids,
|
||||
|
||||
@@ -8,5 +8,5 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["hatasmota"],
|
||||
"mqtt": ["tasmota/discovery/#"],
|
||||
"requirements": ["hatasmota==0.6.4"]
|
||||
"requirements": ["hatasmota==0.6.5"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,11 @@ import logging
|
||||
from typing import Any
|
||||
|
||||
import transmission_rpc
|
||||
from transmission_rpc.error import TransmissionError
|
||||
from transmission_rpc.error import (
|
||||
TransmissionAuthError,
|
||||
TransmissionConnectError,
|
||||
TransmissionError,
|
||||
)
|
||||
import voluptuous as vol
|
||||
|
||||
from homeassistant.config_entries import ConfigEntry, ConfigEntryState
|
||||
@@ -137,14 +141,13 @@ async def get_api(hass, entry):
|
||||
_LOGGER.debug("Successfully connected to %s", host)
|
||||
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:
|
||||
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)
|
||||
raise UnknownError from error
|
||||
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/upb",
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["upb_lib"],
|
||||
"requirements": ["upb_lib==0.5.3"]
|
||||
"requirements": ["upb_lib==0.5.4"]
|
||||
}
|
||||
|
||||
@@ -6,5 +6,5 @@
|
||||
"documentation": "https://www.home-assistant.io/integrations/volvooncall",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["geopy", "hbmqtt", "volvooncall"],
|
||||
"requirements": ["volvooncall==0.10.2"]
|
||||
"requirements": ["volvooncall==0.10.3"]
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
"iot_class": "local_push",
|
||||
"loggers": ["aiowebostv"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiowebostv==0.3.2"],
|
||||
"requirements": ["aiowebostv==0.3.3"],
|
||||
"ssdp": [
|
||||
{
|
||||
"st": "urn:lge-com:service:webos-second-screen:1"
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
{
|
||||
"config": {
|
||||
"abort": {
|
||||
"incorrect_province": "Incorrect subdivision from yaml import"
|
||||
"incorrect_province": "Incorrect subdivision from yaml import",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
},
|
||||
"step": {
|
||||
"user": {
|
||||
@@ -31,8 +32,7 @@
|
||||
},
|
||||
"error": {
|
||||
"add_holiday_error": "Incorrect format on date (YYYY-MM-DD)",
|
||||
"remove_holiday_error": "Incorrect format on date (YYYY-MM-DD) or holiday name not found",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
"remove_holiday_error": "Incorrect format on date (YYYY-MM-DD) or holiday name not found"
|
||||
}
|
||||
},
|
||||
"options": {
|
||||
@@ -59,7 +59,7 @@
|
||||
"error": {
|
||||
"add_holiday_error": "Incorrect format on date (YYYY-MM-DD)",
|
||||
"remove_holiday_error": "Incorrect format on date (YYYY-MM-DD) or holiday name not found",
|
||||
"already_configured": "[%key:common::config_flow::abort::already_configured_service%]"
|
||||
"already_configured": "Service with this configuration already exist"
|
||||
}
|
||||
},
|
||||
"issues": {
|
||||
|
||||
@@ -137,7 +137,19 @@ class Endpoint:
|
||||
):
|
||||
cluster_handler_class = MultistateInput
|
||||
# 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:
|
||||
self._device.power_configuration_ch = cluster_handler
|
||||
elif cluster_handler.name == const.CLUSTER_HANDLER_IDENTIFY:
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
"zigpy_znp"
|
||||
],
|
||||
"requirements": [
|
||||
"bellows==0.35.2",
|
||||
"bellows==0.35.5",
|
||||
"pyserial==3.5",
|
||||
"pyserial-asyncio==0.6",
|
||||
"zha-quirks==0.0.99",
|
||||
|
||||
@@ -84,7 +84,7 @@ bulk_set_partial_config_parameters:
|
||||
value:
|
||||
name: Value
|
||||
description: The new value(s) to set for this configuration parameter. Can either be a raw integer value to represent the bulk change or a mapping where the key is the bitmask (either in hex or integer form) and the value is the new value you want to set for that partial parameter.
|
||||
example:
|
||||
example: |
|
||||
"0x1": 1
|
||||
"0x10": 1
|
||||
"0x20": 1
|
||||
@@ -287,7 +287,7 @@ invoke_cc_api:
|
||||
parameters:
|
||||
name: Parameters
|
||||
description: A list of parameters to pass to the API method. Refer to the Z-Wave JS Command Class API documentation (https://zwave-js.github.io/node-zwave-js/#/api/CCs/index) for parameters.
|
||||
example: [1, 1]
|
||||
example: "[1, 1]"
|
||||
required: true
|
||||
selector:
|
||||
object:
|
||||
|
||||
@@ -8,7 +8,7 @@ from .backports.enum import StrEnum
|
||||
APPLICATION_NAME: Final = "HomeAssistant"
|
||||
MAJOR_VERSION: Final = 2023
|
||||
MINOR_VERSION: Final = 5
|
||||
PATCH_VERSION: Final = "0"
|
||||
PATCH_VERSION: Final = "3"
|
||||
__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)
|
||||
|
||||
@@ -37,6 +37,11 @@ SERVER_SOFTWARE = "{0}/{1} aiohttp/{2} Python/{3[0]}.{3[1]}".format(
|
||||
APPLICATION_NAME, __version__, aiohttp.__version__, sys.version_info
|
||||
)
|
||||
|
||||
ENABLE_CLEANUP_CLOSED = sys.version_info < (3, 11, 1)
|
||||
# Enabling cleanup closed on python 3.11.1+ leaks memory relatively quickly
|
||||
# see https://github.com/aio-libs/aiohttp/issues/7252
|
||||
# aiohttp interacts poorly with https://github.com/python/cpython/pull/98540
|
||||
|
||||
WARN_CLOSE_MSG = "closes the Home Assistant aiohttp session"
|
||||
|
||||
#
|
||||
@@ -276,7 +281,7 @@ def _async_get_connector(
|
||||
ssl_context = ssl_util.get_default_no_verify_context()
|
||||
|
||||
connector = aiohttp.TCPConnector(
|
||||
enable_cleanup_closed=True,
|
||||
enable_cleanup_closed=ENABLE_CLEANUP_CLOSED,
|
||||
ssl=ssl_context,
|
||||
limit=MAXIMUM_CONNECTIONS,
|
||||
limit_per_host=MAXIMUM_CONNECTIONS_PER_HOST,
|
||||
|
||||
@@ -763,13 +763,6 @@ class Entity(ABC):
|
||||
hass = self.hass
|
||||
assert hass is not None
|
||||
|
||||
if hasattr(self, "async_update"):
|
||||
coro: asyncio.Future[None] = self.async_update()
|
||||
elif hasattr(self, "update"):
|
||||
coro = hass.async_add_executor_job(self.update)
|
||||
else:
|
||||
return
|
||||
|
||||
self._update_staged = True
|
||||
|
||||
# Process update sequential
|
||||
@@ -780,8 +773,14 @@ class Entity(ABC):
|
||||
update_warn = hass.loop.call_later(
|
||||
SLOW_UPDATE_WARNING, self._async_slow_update_warning
|
||||
)
|
||||
|
||||
try:
|
||||
await coro
|
||||
if hasattr(self, "async_update"):
|
||||
await self.async_update()
|
||||
elif hasattr(self, "update"):
|
||||
await hass.async_add_executor_job(self.update)
|
||||
else:
|
||||
return
|
||||
finally:
|
||||
self._update_staged = False
|
||||
if warning:
|
||||
|
||||
@@ -14,7 +14,7 @@ bcrypt==4.0.1
|
||||
bleak-retry-connector==3.0.2
|
||||
bleak==0.20.2
|
||||
bluetooth-adapters==0.15.3
|
||||
bluetooth-auto-recovery==1.1.1
|
||||
bluetooth-auto-recovery==1.2.0
|
||||
bluetooth-data-tools==0.4.0
|
||||
certifi>=2021.5.30
|
||||
ciso8601==2.3.0
|
||||
@@ -25,7 +25,7 @@ ha-av==10.0.0
|
||||
hass-nabucasa==0.66.2
|
||||
hassil==1.0.6
|
||||
home-assistant-bluetooth==1.10.0
|
||||
home-assistant-frontend==20230503.1
|
||||
home-assistant-frontend==20230503.3
|
||||
home-assistant-intents==2023.4.26
|
||||
httpx==0.24.0
|
||||
ifaddr==0.1.7
|
||||
|
||||
@@ -54,6 +54,20 @@ def is_region(language: str, region: str | None) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
def is_language_match(lang_1: str, lang_2: str) -> bool:
|
||||
"""Return true if two languages are considered the same."""
|
||||
if lang_1 == lang_2:
|
||||
# Exact match
|
||||
return True
|
||||
|
||||
if {lang_1, lang_2} == {"no", "nb"}:
|
||||
# no = spoken Norwegian
|
||||
# nb = written Norwegian (Bokmål)
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
|
||||
@dataclass
|
||||
class Dialect:
|
||||
"""Language with optional region and script/code."""
|
||||
@@ -71,26 +85,35 @@ class Dialect:
|
||||
# Regions are upper-cased
|
||||
self.region = self.region.upper()
|
||||
|
||||
def score(self, dialect: Dialect, country: str | None = None) -> float:
|
||||
def score(
|
||||
self, dialect: Dialect, country: str | None = None
|
||||
) -> tuple[float, float]:
|
||||
"""Return score for match with another dialect where higher is better.
|
||||
|
||||
Score < 0 indicates a failure to match.
|
||||
"""
|
||||
if self.language != dialect.language:
|
||||
if not is_language_match(self.language, dialect.language):
|
||||
# Not a match
|
||||
return -1
|
||||
return (-1, 0)
|
||||
|
||||
is_exact_language = self.language == dialect.language
|
||||
|
||||
if (self.region is None) and (dialect.region is None):
|
||||
# Weak match with no region constraint
|
||||
return 1
|
||||
# Prefer exact language match
|
||||
return (2 if is_exact_language else 1, 0)
|
||||
|
||||
if (self.region is not None) and (dialect.region is not None):
|
||||
if self.region == dialect.region:
|
||||
# Exact language + region match
|
||||
return math.inf
|
||||
# Same language + region match
|
||||
# Prefer exact language match
|
||||
return (
|
||||
math.inf,
|
||||
1 if is_exact_language else 0,
|
||||
)
|
||||
|
||||
# Regions are both set, but don't match
|
||||
return 0
|
||||
return (0, 0)
|
||||
|
||||
# Generate ordered list of preferred regions
|
||||
pref_regions = list(
|
||||
@@ -113,13 +136,13 @@ class Dialect:
|
||||
|
||||
# More preferred regions are at the front.
|
||||
# Add 1 to boost above a weak match where no regions are set.
|
||||
return 1 + (len(pref_regions) - region_idx)
|
||||
return (1 + (len(pref_regions) - region_idx), 0)
|
||||
except ValueError:
|
||||
# Region was not in preferred list
|
||||
pass
|
||||
|
||||
# Not a preferred region
|
||||
return 0
|
||||
return (0, 0)
|
||||
|
||||
@staticmethod
|
||||
def parse(tag: str) -> Dialect:
|
||||
@@ -169,4 +192,4 @@ def matches(
|
||||
)
|
||||
|
||||
# Score < 0 is not a match
|
||||
return [tag for _dialect, score, tag in scored if score >= 0]
|
||||
return [tag for _dialect, score, tag in scored if score[0] >= 0]
|
||||
|
||||
@@ -73,8 +73,6 @@ def create_no_verify_ssl_context(
|
||||
https://github.com/aio-libs/aiohttp/blob/33953f110e97eecc707e1402daa8d543f38a189b/aiohttp/connector.py#L911
|
||||
"""
|
||||
sslcontext = ssl.SSLContext(ssl.PROTOCOL_TLS_CLIENT)
|
||||
sslcontext.options |= ssl.OP_NO_SSLv2
|
||||
sslcontext.options |= ssl.OP_NO_SSLv3
|
||||
sslcontext.check_hostname = False
|
||||
sslcontext.verify_mode = ssl.CERT_NONE
|
||||
with contextlib.suppress(AttributeError):
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "homeassistant"
|
||||
version = "2023.5.0"
|
||||
version = "2023.5.3"
|
||||
license = {text = "Apache-2.0"}
|
||||
description = "Open-source home automation platform running on Python 3."
|
||||
readme = "README.rst"
|
||||
|
||||
@@ -116,7 +116,7 @@ aio_georss_gdacs==0.8
|
||||
aioairq==0.2.4
|
||||
|
||||
# homeassistant.components.airzone
|
||||
aioairzone==0.5.2
|
||||
aioairzone==0.5.5
|
||||
|
||||
# homeassistant.components.ambient_station
|
||||
aioambient==2023.04.0
|
||||
@@ -156,7 +156,7 @@ aioecowitt==2023.01.0
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==13.7.2
|
||||
aioesphomeapi==13.7.4
|
||||
|
||||
# homeassistant.components.flo
|
||||
aioflo==2021.11.0
|
||||
@@ -223,7 +223,7 @@ aionanoleaf==0.2.1
|
||||
aionotify==0.2.0
|
||||
|
||||
# homeassistant.components.notion
|
||||
aionotion==2023.04.2
|
||||
aionotion==2023.05.4
|
||||
|
||||
# homeassistant.components.oncue
|
||||
aiooncue==0.3.4
|
||||
@@ -300,7 +300,7 @@ aiovlc==0.1.0
|
||||
aiowatttime==0.1.1
|
||||
|
||||
# homeassistant.components.webostv
|
||||
aiowebostv==0.3.2
|
||||
aiowebostv==0.3.3
|
||||
|
||||
# homeassistant.components.yandex_transport
|
||||
aioymaps==1.2.2
|
||||
@@ -383,7 +383,7 @@ async-upnp-client==0.33.1
|
||||
asyncpysupla==0.0.5
|
||||
|
||||
# homeassistant.components.sleepiq
|
||||
asyncsleepiq==1.3.4
|
||||
asyncsleepiq==1.3.5
|
||||
|
||||
# homeassistant.components.aten_pe
|
||||
# atenpdu==0.3.2
|
||||
@@ -428,10 +428,10 @@ beautifulsoup4==4.11.1
|
||||
# beewi_smartclim==0.0.10
|
||||
|
||||
# homeassistant.components.zha
|
||||
bellows==0.35.2
|
||||
bellows==0.35.5
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer_connected==0.13.2
|
||||
bimmer_connected==0.13.3
|
||||
|
||||
# homeassistant.components.bizkaibus
|
||||
bizkaibus==0.1.1
|
||||
@@ -465,7 +465,7 @@ bluemaestro-ble==0.2.3
|
||||
bluetooth-adapters==0.15.3
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bluetooth-auto-recovery==1.1.1
|
||||
bluetooth-auto-recovery==1.2.0
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
# homeassistant.components.esphome
|
||||
@@ -644,7 +644,7 @@ elgato==4.0.1
|
||||
eliqonline==1.2.2
|
||||
|
||||
# homeassistant.components.elkm1
|
||||
elkm1-lib==2.2.1
|
||||
elkm1-lib==2.2.2
|
||||
|
||||
# homeassistant.components.elmax
|
||||
elmax_api==0.0.4
|
||||
@@ -683,7 +683,7 @@ epsonprinter==0.0.9
|
||||
esphome-dashboard-api==1.2.3
|
||||
|
||||
# homeassistant.components.netgear_lte
|
||||
eternalegypt==0.0.15
|
||||
eternalegypt==0.0.16
|
||||
|
||||
# homeassistant.components.eufylife_ble
|
||||
eufylife_ble_client==0.1.7
|
||||
@@ -881,7 +881,7 @@ hass_splunk==0.1.1
|
||||
hassil==1.0.6
|
||||
|
||||
# homeassistant.components.tasmota
|
||||
hatasmota==0.6.4
|
||||
hatasmota==0.6.5
|
||||
|
||||
# homeassistant.components.jewish_calendar
|
||||
hdate==0.10.4
|
||||
@@ -911,7 +911,7 @@ hole==0.8.0
|
||||
holidays==0.21.13
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20230503.1
|
||||
home-assistant-frontend==20230503.3
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2023.4.26
|
||||
@@ -1264,7 +1264,7 @@ ondilo==0.2.0
|
||||
onkyo-eiscp==1.2.7
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==1.3.1
|
||||
onvif-zeep-async==2.1.1
|
||||
|
||||
# homeassistant.components.opengarage
|
||||
open-garage==0.2.0
|
||||
@@ -1859,7 +1859,7 @@ pyotgw==2.1.3
|
||||
pyotp==2.8.0
|
||||
|
||||
# homeassistant.components.overkiz
|
||||
pyoverkiz==1.7.7
|
||||
pyoverkiz==1.7.8
|
||||
|
||||
# homeassistant.components.openweathermap
|
||||
pyowm==3.2.0
|
||||
@@ -2242,7 +2242,7 @@ regenmaschine==2022.11.0
|
||||
renault-api==0.1.13
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.5.13
|
||||
reolink-aio==0.5.15
|
||||
|
||||
# homeassistant.components.python_script
|
||||
restrictedpython==6.0
|
||||
@@ -2390,7 +2390,7 @@ solax==0.3.0
|
||||
somfy-mylink-synergy==1.0.6
|
||||
|
||||
# homeassistant.components.sonos
|
||||
sonos-websocket==0.1.0
|
||||
sonos-websocket==0.1.1
|
||||
|
||||
# homeassistant.components.marytts
|
||||
speak2mary==1.4.0
|
||||
@@ -2565,7 +2565,7 @@ unifi-discovery==1.1.7
|
||||
unifiled==0.11
|
||||
|
||||
# homeassistant.components.upb
|
||||
upb_lib==0.5.3
|
||||
upb_lib==0.5.4
|
||||
|
||||
# homeassistant.components.upcloud
|
||||
upcloud-api==2.0.0
|
||||
@@ -2582,7 +2582,7 @@ uvcclient==0.11.0
|
||||
vallox-websocket-api==3.2.1
|
||||
|
||||
# homeassistant.components.rdw
|
||||
vehicle==1.0.0
|
||||
vehicle==1.0.1
|
||||
|
||||
# homeassistant.components.velbus
|
||||
velbus-aio==2023.2.0
|
||||
@@ -2600,7 +2600,7 @@ voip-utils==0.0.7
|
||||
volkszaehler==0.4.0
|
||||
|
||||
# homeassistant.components.volvooncall
|
||||
volvooncall==0.10.2
|
||||
volvooncall==0.10.3
|
||||
|
||||
# homeassistant.components.verisure
|
||||
vsure==2.6.1
|
||||
|
||||
@@ -106,7 +106,7 @@ aio_georss_gdacs==0.8
|
||||
aioairq==0.2.4
|
||||
|
||||
# homeassistant.components.airzone
|
||||
aioairzone==0.5.2
|
||||
aioairzone==0.5.5
|
||||
|
||||
# homeassistant.components.ambient_station
|
||||
aioambient==2023.04.0
|
||||
@@ -146,7 +146,7 @@ aioecowitt==2023.01.0
|
||||
aioemonitor==1.0.5
|
||||
|
||||
# homeassistant.components.esphome
|
||||
aioesphomeapi==13.7.2
|
||||
aioesphomeapi==13.7.4
|
||||
|
||||
# homeassistant.components.flo
|
||||
aioflo==2021.11.0
|
||||
@@ -204,7 +204,7 @@ aiomusiccast==0.14.8
|
||||
aionanoleaf==0.2.1
|
||||
|
||||
# homeassistant.components.notion
|
||||
aionotion==2023.04.2
|
||||
aionotion==2023.05.4
|
||||
|
||||
# homeassistant.components.oncue
|
||||
aiooncue==0.3.4
|
||||
@@ -281,7 +281,7 @@ aiovlc==0.1.0
|
||||
aiowatttime==0.1.1
|
||||
|
||||
# homeassistant.components.webostv
|
||||
aiowebostv==0.3.2
|
||||
aiowebostv==0.3.3
|
||||
|
||||
# homeassistant.components.yandex_transport
|
||||
aioymaps==1.2.2
|
||||
@@ -340,7 +340,7 @@ arcam-fmj==1.3.0
|
||||
async-upnp-client==0.33.1
|
||||
|
||||
# homeassistant.components.sleepiq
|
||||
asyncsleepiq==1.3.4
|
||||
asyncsleepiq==1.3.5
|
||||
|
||||
# homeassistant.components.aurora
|
||||
auroranoaa==0.0.3
|
||||
@@ -361,10 +361,10 @@ base36==0.1.1
|
||||
beautifulsoup4==4.11.1
|
||||
|
||||
# homeassistant.components.zha
|
||||
bellows==0.35.2
|
||||
bellows==0.35.5
|
||||
|
||||
# homeassistant.components.bmw_connected_drive
|
||||
bimmer_connected==0.13.2
|
||||
bimmer_connected==0.13.3
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bleak-retry-connector==3.0.2
|
||||
@@ -385,7 +385,7 @@ bluemaestro-ble==0.2.3
|
||||
bluetooth-adapters==0.15.3
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
bluetooth-auto-recovery==1.1.1
|
||||
bluetooth-auto-recovery==1.2.0
|
||||
|
||||
# homeassistant.components.bluetooth
|
||||
# homeassistant.components.esphome
|
||||
@@ -506,7 +506,7 @@ easyenergy==0.3.0
|
||||
elgato==4.0.1
|
||||
|
||||
# homeassistant.components.elkm1
|
||||
elkm1-lib==2.2.1
|
||||
elkm1-lib==2.2.2
|
||||
|
||||
# homeassistant.components.elmax
|
||||
elmax_api==0.0.4
|
||||
@@ -679,7 +679,7 @@ hass-nabucasa==0.66.2
|
||||
hassil==1.0.6
|
||||
|
||||
# homeassistant.components.tasmota
|
||||
hatasmota==0.6.4
|
||||
hatasmota==0.6.5
|
||||
|
||||
# homeassistant.components.jewish_calendar
|
||||
hdate==0.10.4
|
||||
@@ -700,7 +700,7 @@ hole==0.8.0
|
||||
holidays==0.21.13
|
||||
|
||||
# homeassistant.components.frontend
|
||||
home-assistant-frontend==20230503.1
|
||||
home-assistant-frontend==20230503.3
|
||||
|
||||
# homeassistant.components.conversation
|
||||
home-assistant-intents==2023.4.26
|
||||
@@ -945,7 +945,7 @@ omnilogic==0.4.5
|
||||
ondilo==0.2.0
|
||||
|
||||
# homeassistant.components.onvif
|
||||
onvif-zeep-async==1.3.1
|
||||
onvif-zeep-async==2.1.1
|
||||
|
||||
# homeassistant.components.opengarage
|
||||
open-garage==0.2.0
|
||||
@@ -1357,7 +1357,7 @@ pyotgw==2.1.3
|
||||
pyotp==2.8.0
|
||||
|
||||
# homeassistant.components.overkiz
|
||||
pyoverkiz==1.7.7
|
||||
pyoverkiz==1.7.8
|
||||
|
||||
# homeassistant.components.openweathermap
|
||||
pyowm==3.2.0
|
||||
@@ -1611,7 +1611,7 @@ regenmaschine==2022.11.0
|
||||
renault-api==0.1.13
|
||||
|
||||
# homeassistant.components.reolink
|
||||
reolink-aio==0.5.13
|
||||
reolink-aio==0.5.15
|
||||
|
||||
# homeassistant.components.python_script
|
||||
restrictedpython==6.0
|
||||
@@ -1714,7 +1714,7 @@ solax==0.3.0
|
||||
somfy-mylink-synergy==1.0.6
|
||||
|
||||
# homeassistant.components.sonos
|
||||
sonos-websocket==0.1.0
|
||||
sonos-websocket==0.1.1
|
||||
|
||||
# homeassistant.components.marytts
|
||||
speak2mary==1.4.0
|
||||
@@ -1841,7 +1841,7 @@ ultraheat-api==0.5.1
|
||||
unifi-discovery==1.1.7
|
||||
|
||||
# homeassistant.components.upb
|
||||
upb_lib==0.5.3
|
||||
upb_lib==0.5.4
|
||||
|
||||
# homeassistant.components.upcloud
|
||||
upcloud-api==2.0.0
|
||||
@@ -1858,7 +1858,7 @@ uvcclient==0.11.0
|
||||
vallox-websocket-api==3.2.1
|
||||
|
||||
# homeassistant.components.rdw
|
||||
vehicle==1.0.0
|
||||
vehicle==1.0.1
|
||||
|
||||
# homeassistant.components.velbus
|
||||
velbus-aio==2023.2.0
|
||||
@@ -1873,7 +1873,7 @@ vilfo-api-client==0.3.2
|
||||
voip-utils==0.0.7
|
||||
|
||||
# homeassistant.components.volvooncall
|
||||
volvooncall==0.10.2
|
||||
volvooncall==0.10.3
|
||||
|
||||
# homeassistant.components.verisure
|
||||
vsure==2.6.1
|
||||
|
||||
@@ -84,3 +84,9 @@ async def test_airzone_create_binary_sensors(hass: HomeAssistant) -> None:
|
||||
|
||||
state = hass.states.get("binary_sensor.airzone_2_1_problem")
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
state = hass.states.get("binary_sensor.dkn_plus_battery_low")
|
||||
assert state is None
|
||||
|
||||
state = hass.states.get("binary_sensor.dkn_plus_problem")
|
||||
assert state.state == STATE_OFF
|
||||
|
||||
@@ -145,6 +145,24 @@ async def test_airzone_create_climates(hass: HomeAssistant) -> None:
|
||||
assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP
|
||||
assert state.attributes.get(ATTR_TEMPERATURE) == 19.0
|
||||
|
||||
state = hass.states.get("climate.dkn_plus")
|
||||
assert state.state == HVACMode.HEAT_COOL
|
||||
assert state.attributes.get(ATTR_CURRENT_HUMIDITY) is None
|
||||
assert state.attributes.get(ATTR_CURRENT_TEMPERATURE) == 21.7
|
||||
assert state.attributes.get(ATTR_HVAC_ACTION) == HVACAction.COOLING
|
||||
assert state.attributes.get(ATTR_HVAC_MODES) == [
|
||||
HVACMode.FAN_ONLY,
|
||||
HVACMode.COOL,
|
||||
HVACMode.HEAT,
|
||||
HVACMode.DRY,
|
||||
HVACMode.HEAT_COOL,
|
||||
HVACMode.OFF,
|
||||
]
|
||||
assert state.attributes.get(ATTR_MAX_TEMP) == 32.2
|
||||
assert state.attributes.get(ATTR_MIN_TEMP) == 17.8
|
||||
assert state.attributes.get(ATTR_TARGET_TEMP_STEP) == API_TEMPERATURE_STEP
|
||||
assert state.attributes.get(ATTR_TEMPERATURE) == 22.8
|
||||
|
||||
|
||||
async def test_airzone_climate_turn_on_off(hass: HomeAssistant) -> None:
|
||||
"""Test turning on."""
|
||||
|
||||
@@ -52,3 +52,9 @@ async def test_airzone_create_sensors(
|
||||
|
||||
state = hass.states.get("sensor.airzone_2_1_humidity")
|
||||
assert state.state == "62"
|
||||
|
||||
state = hass.states.get("sensor.dkn_plus_temperature")
|
||||
assert state.state == "21.7"
|
||||
|
||||
state = hass.states.get("sensor.dkn_plus_humidity")
|
||||
assert state is None
|
||||
|
||||
@@ -7,10 +7,16 @@ from aioairzone.const import (
|
||||
API_COLD_ANGLE,
|
||||
API_COLD_STAGE,
|
||||
API_COLD_STAGES,
|
||||
API_COOL_MAX_TEMP,
|
||||
API_COOL_MIN_TEMP,
|
||||
API_COOL_SET_POINT,
|
||||
API_DATA,
|
||||
API_ERRORS,
|
||||
API_FLOOR_DEMAND,
|
||||
API_HEAT_ANGLE,
|
||||
API_HEAT_MAX_TEMP,
|
||||
API_HEAT_MIN_TEMP,
|
||||
API_HEAT_SET_POINT,
|
||||
API_HEAT_STAGE,
|
||||
API_HEAT_STAGES,
|
||||
API_HUMIDITY,
|
||||
@@ -25,6 +31,8 @@ from aioairzone.const import (
|
||||
API_ROOM_TEMP,
|
||||
API_SET_POINT,
|
||||
API_SLEEP,
|
||||
API_SPEED,
|
||||
API_SPEEDS,
|
||||
API_SYSTEM_FIRMWARE,
|
||||
API_SYSTEM_ID,
|
||||
API_SYSTEM_TYPE,
|
||||
@@ -216,6 +224,39 @@ HVAC_MOCK = {
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
API_DATA: [
|
||||
{
|
||||
API_SYSTEM_ID: 3,
|
||||
API_ZONE_ID: 1,
|
||||
API_NAME: "DKN Plus",
|
||||
API_ON: 1,
|
||||
API_COOL_SET_POINT: 73,
|
||||
API_COOL_MAX_TEMP: 90,
|
||||
API_COOL_MIN_TEMP: 64,
|
||||
API_HEAT_SET_POINT: 77,
|
||||
API_HEAT_MAX_TEMP: 86,
|
||||
API_HEAT_MIN_TEMP: 50,
|
||||
API_MAX_TEMP: 90,
|
||||
API_MIN_TEMP: 64,
|
||||
API_SET_POINT: 73,
|
||||
API_ROOM_TEMP: 71,
|
||||
API_MODES: [4, 2, 3, 5, 7],
|
||||
API_MODE: 7,
|
||||
API_SPEEDS: 5,
|
||||
API_SPEED: 2,
|
||||
API_COLD_STAGES: 0,
|
||||
API_COLD_STAGE: 0,
|
||||
API_HEAT_STAGES: 0,
|
||||
API_HEAT_STAGE: 0,
|
||||
API_HUMIDITY: 0,
|
||||
API_UNITS: 1,
|
||||
API_ERRORS: [],
|
||||
API_AIR_DEMAND: 1,
|
||||
API_FLOOR_DEMAND: 0,
|
||||
},
|
||||
]
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
21
tests/components/alexa/test_config.py
Normal file
21
tests/components/alexa/test_config.py
Normal file
@@ -0,0 +1,21 @@
|
||||
"""Test config."""
|
||||
import asyncio
|
||||
from unittest.mock import patch
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from .test_common import get_default_config
|
||||
|
||||
|
||||
async def test_enable_proactive_mode_in_parallel(hass: HomeAssistant) -> None:
|
||||
"""Test enabling proactive mode does not happen in parallel."""
|
||||
config = get_default_config(hass)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.alexa.config.async_enable_proactive_mode"
|
||||
) as mock_enable_proactive_mode:
|
||||
await asyncio.gather(
|
||||
config.async_enable_proactive_mode(), config.async_enable_proactive_mode()
|
||||
)
|
||||
|
||||
mock_enable_proactive_mode.assert_awaited_once()
|
||||
@@ -542,11 +542,13 @@ async def test_alexa_handle_logout(
|
||||
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(
|
||||
hass: HomeAssistant,
|
||||
cloud_prefs: CloudPreferences,
|
||||
cloud_stub,
|
||||
entity_registry: er.EntityRegistry,
|
||||
alexa_settings_version: int,
|
||||
) -> None:
|
||||
"""Test migrating Alexa entity config."""
|
||||
hass.state = CoreState.starting
|
||||
@@ -593,7 +595,7 @@ async def test_alexa_config_migrate_expose_entity_prefs(
|
||||
await cloud_prefs.async_update(
|
||||
alexa_enabled=True,
|
||||
alexa_report_state=False,
|
||||
alexa_settings_version=1,
|
||||
alexa_settings_version=alexa_settings_version,
|
||||
)
|
||||
expose_entity(hass, entity_migrated.entity_id, False)
|
||||
|
||||
@@ -628,7 +630,7 @@ async def test_alexa_config_migrate_expose_entity_prefs(
|
||||
"cloud.alexa": {"should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, entity_migrated.entity_id) == {
|
||||
"cloud.alexa": {"should_expose": False}
|
||||
"cloud.alexa": {"should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, entity_config.entity_id) == {
|
||||
"cloud.alexa": {"should_expose": 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(
|
||||
hass: HomeAssistant,
|
||||
cloud_prefs: CloudPreferences,
|
||||
|
||||
@@ -483,10 +483,12 @@ async def test_google_handle_logout(
|
||||
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(
|
||||
hass: HomeAssistant,
|
||||
cloud_prefs: CloudPreferences,
|
||||
entity_registry: er.EntityRegistry,
|
||||
google_settings_version: int,
|
||||
) -> None:
|
||||
"""Test migrating Google entity config."""
|
||||
hass.state = CoreState.starting
|
||||
@@ -540,7 +542,7 @@ async def test_google_config_migrate_expose_entity_prefs(
|
||||
await cloud_prefs.async_update(
|
||||
google_enabled=True,
|
||||
google_report_state=False,
|
||||
google_settings_version=1,
|
||||
google_settings_version=google_settings_version,
|
||||
)
|
||||
expose_entity(hass, entity_migrated.entity_id, False)
|
||||
|
||||
@@ -580,7 +582,7 @@ async def test_google_config_migrate_expose_entity_prefs(
|
||||
"cloud.google_assistant": {"should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, entity_migrated.entity_id) == {
|
||||
"cloud.google_assistant": {"should_expose": False}
|
||||
"cloud.google_assistant": {"should_expose": True}
|
||||
}
|
||||
assert async_get_entity_settings(hass, entity_no_2fa_exposed.entity_id) == {
|
||||
"cloud.google_assistant": {"disable_2fa": True, "should_expose": True}
|
||||
@@ -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(
|
||||
hass: HomeAssistant,
|
||||
cloud_prefs: CloudPreferences,
|
||||
|
||||
@@ -36,6 +36,7 @@ from homeassistant.components.light import (
|
||||
ATTR_TRANSITION,
|
||||
ATTR_XY_COLOR,
|
||||
DOMAIN as LIGHT_DOMAIN,
|
||||
SERVICE_TURN_ON,
|
||||
ColorMode,
|
||||
)
|
||||
from homeassistant.const import (
|
||||
@@ -1741,3 +1742,56 @@ async def test_set_hev_cycle_state_fails_for_color_bulb(hass: HomeAssistant) ->
|
||||
{ATTR_ENTITY_ID: entity_id, ATTR_POWER: True},
|
||||
blocking=True,
|
||||
)
|
||||
|
||||
|
||||
async def test_light_strip_zones_not_populated_yet(hass: HomeAssistant) -> None:
|
||||
"""Test a light strip were zones are not populated initially."""
|
||||
already_migrated_config_entry = MockConfigEntry(
|
||||
domain=DOMAIN, data={CONF_HOST: "127.0.0.1"}, unique_id=SERIAL
|
||||
)
|
||||
already_migrated_config_entry.add_to_hass(hass)
|
||||
bulb = _mocked_light_strip()
|
||||
bulb.power_level = 65535
|
||||
bulb.color_zones = None
|
||||
bulb.color = [65535, 65535, 65535, 65535]
|
||||
assert bulb.get_color_zones.calls == []
|
||||
|
||||
with _patch_discovery(device=bulb), _patch_config_flow_try_connect(
|
||||
device=bulb
|
||||
), _patch_device(device=bulb):
|
||||
await async_setup_component(hass, lifx.DOMAIN, {lifx.DOMAIN: {}})
|
||||
await hass.async_block_till_done()
|
||||
|
||||
entity_id = "light.my_bulb"
|
||||
# Make sure we at least try to fetch the first zone
|
||||
# to ensure we populate the zones from the 503 response
|
||||
assert len(bulb.get_color_zones.calls) == 3
|
||||
# Once to populate the number of zones
|
||||
assert bulb.get_color_zones.calls[0][1]["start_index"] == 0
|
||||
# Again once we know the number of zones
|
||||
assert bulb.get_color_zones.calls[1][1]["start_index"] == 0
|
||||
assert bulb.get_color_zones.calls[2][1]["start_index"] == 8
|
||||
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == "on"
|
||||
attributes = state.attributes
|
||||
assert attributes[ATTR_BRIGHTNESS] == 255
|
||||
assert attributes[ATTR_COLOR_MODE] == ColorMode.HS
|
||||
assert attributes[ATTR_SUPPORTED_COLOR_MODES] == [
|
||||
ColorMode.COLOR_TEMP,
|
||||
ColorMode.HS,
|
||||
]
|
||||
assert attributes[ATTR_HS_COLOR] == (360.0, 100.0)
|
||||
assert attributes[ATTR_RGB_COLOR] == (255, 0, 0)
|
||||
assert attributes[ATTR_XY_COLOR] == (0.701, 0.299)
|
||||
|
||||
await hass.services.async_call(
|
||||
LIGHT_DOMAIN, SERVICE_TURN_ON, {ATTR_ENTITY_ID: entity_id}, blocking=True
|
||||
)
|
||||
assert bulb.set_power.calls[0][0][0] is True
|
||||
bulb.set_power.reset_mock()
|
||||
|
||||
async_fire_time_changed(hass, dt_util.utcnow() + timedelta(seconds=30))
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == STATE_ON
|
||||
|
||||
@@ -3,8 +3,9 @@ from collections.abc import Generator
|
||||
import json
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from aionotion.bridge.models import Bridge
|
||||
from aionotion.sensor.models import Listener, Sensor
|
||||
from aionotion.bridge.models import BridgeAllResponse
|
||||
from aionotion.sensor.models import ListenerAllResponse, SensorAllResponse
|
||||
from aionotion.user.models import UserPreferencesResponse
|
||||
import pytest
|
||||
|
||||
from homeassistant.components.notion import DOMAIN
|
||||
@@ -27,24 +28,23 @@ def mock_setup_entry() -> Generator[AsyncMock, None, None]:
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def client_fixture(data_bridge, data_listener, data_sensor):
|
||||
def client_fixture(data_bridge, data_listener, data_sensor, data_user_preferences):
|
||||
"""Define a fixture for an aionotion client."""
|
||||
return Mock(
|
||||
bridge=Mock(
|
||||
async_all=AsyncMock(
|
||||
return_value=[Bridge.parse_obj(bridge) for bridge in data_bridge]
|
||||
)
|
||||
async_all=AsyncMock(return_value=BridgeAllResponse.parse_obj(data_bridge))
|
||||
),
|
||||
sensor=Mock(
|
||||
async_all=AsyncMock(
|
||||
return_value=[Sensor.parse_obj(sensor) for sensor in data_sensor]
|
||||
),
|
||||
async_all=AsyncMock(return_value=SensorAllResponse.parse_obj(data_sensor)),
|
||||
async_listeners=AsyncMock(
|
||||
return_value=[
|
||||
Listener.parse_obj(listener) for listener in data_listener
|
||||
]
|
||||
return_value=ListenerAllResponse.parse_obj(data_listener)
|
||||
),
|
||||
),
|
||||
user=Mock(
|
||||
async_preferences=AsyncMock(
|
||||
return_value=UserPreferencesResponse.parse_obj(data_user_preferences)
|
||||
)
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -83,6 +83,12 @@ def data_sensor_fixture():
|
||||
return json.loads(load_fixture("sensor_data.json", "notion"))
|
||||
|
||||
|
||||
@pytest.fixture(name="data_user_preferences", scope="package")
|
||||
def data_user_preferences_fixture():
|
||||
"""Define user preferences data."""
|
||||
return json.loads(load_fixture("user_preferences_data.json", "notion"))
|
||||
|
||||
|
||||
@pytest.fixture(name="get_client")
|
||||
def get_client_fixture(client):
|
||||
"""Define a fixture to mock the async_get_client method."""
|
||||
|
||||
@@ -1,50 +1,52 @@
|
||||
[
|
||||
{
|
||||
"id": 12345,
|
||||
"name": "Bridge 1",
|
||||
"mode": "home",
|
||||
"hardware_id": "0x0000000000000000",
|
||||
"hardware_revision": 4,
|
||||
"firmware_version": {
|
||||
"silabs": "1.1.2",
|
||||
"wifi": "0.121.0",
|
||||
"wifi_app": "3.3.0"
|
||||
{
|
||||
"base_stations": [
|
||||
{
|
||||
"id": 12345,
|
||||
"name": "Bridge 1",
|
||||
"mode": "home",
|
||||
"hardware_id": "0x0000000000000000",
|
||||
"hardware_revision": 4,
|
||||
"firmware_version": {
|
||||
"silabs": "1.1.2",
|
||||
"wifi": "0.121.0",
|
||||
"wifi_app": "3.3.0"
|
||||
},
|
||||
"missing_at": null,
|
||||
"created_at": "2019-06-27T00:18:44.337Z",
|
||||
"updated_at": "2023-03-19T03:20:16.061Z",
|
||||
"system_id": 11111,
|
||||
"firmware": {
|
||||
"silabs": "1.1.2",
|
||||
"wifi": "0.121.0",
|
||||
"wifi_app": "3.3.0"
|
||||
},
|
||||
"links": {
|
||||
"system": 11111
|
||||
}
|
||||
},
|
||||
"missing_at": null,
|
||||
"created_at": "2019-06-27T00:18:44.337Z",
|
||||
"updated_at": "2023-03-19T03:20:16.061Z",
|
||||
"system_id": 11111,
|
||||
"firmware": {
|
||||
"silabs": "1.1.2",
|
||||
"wifi": "0.121.0",
|
||||
"wifi_app": "3.3.0"
|
||||
},
|
||||
"links": {
|
||||
"system": 11111
|
||||
{
|
||||
"id": 67890,
|
||||
"name": "Bridge 2",
|
||||
"mode": "home",
|
||||
"hardware_id": "0x0000000000000000",
|
||||
"hardware_revision": 4,
|
||||
"firmware_version": {
|
||||
"wifi": "0.121.0",
|
||||
"wifi_app": "3.3.0",
|
||||
"silabs": "1.1.2"
|
||||
},
|
||||
"missing_at": null,
|
||||
"created_at": "2019-04-30T01:43:50.497Z",
|
||||
"updated_at": "2023-01-02T19:09:58.251Z",
|
||||
"system_id": 11111,
|
||||
"firmware": {
|
||||
"wifi": "0.121.0",
|
||||
"wifi_app": "3.3.0",
|
||||
"silabs": "1.1.2"
|
||||
},
|
||||
"links": {
|
||||
"system": 11111
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": 67890,
|
||||
"name": "Bridge 2",
|
||||
"mode": "home",
|
||||
"hardware_id": "0x0000000000000000",
|
||||
"hardware_revision": 4,
|
||||
"firmware_version": {
|
||||
"wifi": "0.121.0",
|
||||
"wifi_app": "3.3.0",
|
||||
"silabs": "1.1.2"
|
||||
},
|
||||
"missing_at": null,
|
||||
"created_at": "2019-04-30T01:43:50.497Z",
|
||||
"updated_at": "2023-01-02T19:09:58.251Z",
|
||||
"system_id": 11111,
|
||||
"firmware": {
|
||||
"wifi": "0.121.0",
|
||||
"wifi_app": "3.3.0",
|
||||
"silabs": "1.1.2"
|
||||
},
|
||||
"links": {
|
||||
"system": 11111
|
||||
}
|
||||
}
|
||||
]
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,55 +1,57 @@
|
||||
[
|
||||
{
|
||||
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"definition_id": 4,
|
||||
"created_at": "2019-06-28T22:12:49.651Z",
|
||||
"type": "sensor",
|
||||
"model_version": "2.1",
|
||||
"sensor_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"status": {
|
||||
"trigger_value": "no_leak",
|
||||
"data_received_at": "2022-03-20T08:00:29.763Z"
|
||||
},
|
||||
"status_localized": {
|
||||
"state": "No Leak",
|
||||
"description": "Mar 20 at 2:00am"
|
||||
},
|
||||
"insights": {
|
||||
"primary": {
|
||||
"origin": {
|
||||
"type": "Sensor",
|
||||
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
},
|
||||
"value": "no_leak",
|
||||
{
|
||||
"listeners": [
|
||||
{
|
||||
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"definition_id": 4,
|
||||
"created_at": "2019-06-28T22:12:49.651Z",
|
||||
"type": "sensor",
|
||||
"model_version": "2.1",
|
||||
"sensor_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"status": {
|
||||
"trigger_value": "no_leak",
|
||||
"data_received_at": "2022-03-20T08:00:29.763Z"
|
||||
}
|
||||
},
|
||||
"status_localized": {
|
||||
"state": "No Leak",
|
||||
"description": "Mar 20 at 2:00am"
|
||||
},
|
||||
"insights": {
|
||||
"primary": {
|
||||
"origin": {
|
||||
"type": "Sensor",
|
||||
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
|
||||
},
|
||||
"value": "no_leak",
|
||||
"data_received_at": "2022-03-20T08:00:29.763Z"
|
||||
}
|
||||
},
|
||||
"configuration": {},
|
||||
"pro_monitoring_status": "eligible"
|
||||
},
|
||||
"configuration": {},
|
||||
"pro_monitoring_status": "eligible"
|
||||
},
|
||||
{
|
||||
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"definition_id": 7,
|
||||
"created_at": "2019-07-10T22:40:48.847Z",
|
||||
"type": "sensor",
|
||||
"model_version": "3.1",
|
||||
"sensor_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"status": {
|
||||
"trigger_value": "no_alarm",
|
||||
"data_received_at": "2019-06-28T22:12:49.516Z"
|
||||
},
|
||||
"status_localized": {
|
||||
"state": "No Sound",
|
||||
"description": "Jun 28 at 4:12pm"
|
||||
},
|
||||
"insights": {
|
||||
"primary": {
|
||||
"origin": {},
|
||||
"value": "no_alarm",
|
||||
{
|
||||
"id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"definition_id": 7,
|
||||
"created_at": "2019-07-10T22:40:48.847Z",
|
||||
"type": "sensor",
|
||||
"model_version": "3.1",
|
||||
"sensor_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"status": {
|
||||
"trigger_value": "no_alarm",
|
||||
"data_received_at": "2019-06-28T22:12:49.516Z"
|
||||
}
|
||||
},
|
||||
"configuration": {},
|
||||
"pro_monitoring_status": "eligible"
|
||||
}
|
||||
]
|
||||
},
|
||||
"status_localized": {
|
||||
"state": "No Sound",
|
||||
"description": "Jun 28 at 4:12pm"
|
||||
},
|
||||
"insights": {
|
||||
"primary": {
|
||||
"origin": {},
|
||||
"value": "no_alarm",
|
||||
"data_received_at": "2019-06-28T22:12:49.516Z"
|
||||
}
|
||||
},
|
||||
"configuration": {},
|
||||
"pro_monitoring_status": "eligible"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -1,34 +1,36 @@
|
||||
[
|
||||
{
|
||||
"id": 123456,
|
||||
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"user": {
|
||||
"id": 12345,
|
||||
"email": "user@email.com"
|
||||
},
|
||||
"bridge": {
|
||||
"id": 67890,
|
||||
"hardware_id": "0x0000000000000000"
|
||||
},
|
||||
"last_bridge_hardware_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"name": "Sensor 1",
|
||||
"location_id": 123456,
|
||||
"system_id": 12345,
|
||||
"hardware_id": "0x0000000000000000",
|
||||
"hardware_revision": 5,
|
||||
"firmware_version": "1.1.2",
|
||||
"device_key": "0x0000000000000000",
|
||||
"encryption_key": true,
|
||||
"installed_at": "2019-06-28T22:12:51.209Z",
|
||||
"calibrated_at": "2023-03-07T19:51:56.838Z",
|
||||
"last_reported_at": "2023-04-19T18:09:40.479Z",
|
||||
"missing_at": null,
|
||||
"updated_at": "2023-03-28T13:33:33.801Z",
|
||||
"created_at": "2019-06-28T22:12:20.256Z",
|
||||
"signal_strength": 4,
|
||||
"firmware": {
|
||||
"status": "valid"
|
||||
},
|
||||
"surface_type": null
|
||||
}
|
||||
]
|
||||
{
|
||||
"sensors": [
|
||||
{
|
||||
"id": 123456,
|
||||
"uuid": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"user": {
|
||||
"id": 12345,
|
||||
"email": "user@email.com"
|
||||
},
|
||||
"bridge": {
|
||||
"id": 67890,
|
||||
"hardware_id": "0x0000000000000000"
|
||||
},
|
||||
"last_bridge_hardware_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"name": "Sensor 1",
|
||||
"location_id": 123456,
|
||||
"system_id": 12345,
|
||||
"hardware_id": "0x0000000000000000",
|
||||
"hardware_revision": 5,
|
||||
"firmware_version": "1.1.2",
|
||||
"device_key": "0x0000000000000000",
|
||||
"encryption_key": true,
|
||||
"installed_at": "2019-06-28T22:12:51.209Z",
|
||||
"calibrated_at": "2023-03-07T19:51:56.838Z",
|
||||
"last_reported_at": "2023-04-19T18:09:40.479Z",
|
||||
"missing_at": null,
|
||||
"updated_at": "2023-03-28T13:33:33.801Z",
|
||||
"created_at": "2019-06-28T22:12:20.256Z",
|
||||
"signal_strength": 4,
|
||||
"firmware": {
|
||||
"status": "valid"
|
||||
},
|
||||
"surface_type": null
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
10
tests/components/notion/fixtures/user_preferences_data.json
Normal file
10
tests/components/notion/fixtures/user_preferences_data.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"user_preferences": {
|
||||
"user_id": 12345,
|
||||
"military_time_enabled": false,
|
||||
"celsius_enabled": false,
|
||||
"disconnect_alerts_enabled": true,
|
||||
"home_away_alerts_enabled": false,
|
||||
"battery_alerts_enabled": true
|
||||
}
|
||||
}
|
||||
@@ -86,14 +86,6 @@ async def test_entry_diagnostics(
|
||||
"device_type": "sensor",
|
||||
"model_version": "3.1",
|
||||
"sensor_id": "xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx",
|
||||
"status": {
|
||||
"trigger_value": "no_alarm",
|
||||
"data_received_at": "2019-06-28T22:12:49.516000+00:00",
|
||||
},
|
||||
"status_localized": {
|
||||
"state": "No Sound",
|
||||
"description": "Jun 28 at 4:12pm",
|
||||
},
|
||||
"insights": {
|
||||
"primary": {
|
||||
"origin": {"type": None, "id": None},
|
||||
@@ -103,6 +95,14 @@ async def test_entry_diagnostics(
|
||||
},
|
||||
"configuration": {},
|
||||
"pro_monitoring_status": "eligible",
|
||||
"status": {
|
||||
"trigger_value": "no_alarm",
|
||||
"data_received_at": "2019-06-28T22:12:49.516000+00:00",
|
||||
},
|
||||
"status_localized": {
|
||||
"state": "No Sound",
|
||||
"description": "Jun 28 at 4:12pm",
|
||||
},
|
||||
}
|
||||
],
|
||||
"sensors": [
|
||||
@@ -131,5 +131,13 @@ async def test_entry_diagnostics(
|
||||
"surface_type": None,
|
||||
}
|
||||
],
|
||||
"user_preferences": {
|
||||
"user_id": REDACTED,
|
||||
"military_time_enabled": False,
|
||||
"celsius_enabled": False,
|
||||
"disconnect_alerts_enabled": True,
|
||||
"home_away_alerts_enabled": False,
|
||||
"battery_alerts_enabled": True,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
@@ -98,9 +98,11 @@ def setup_mock_onvif_camera(
|
||||
)
|
||||
else:
|
||||
mock_onvif_camera.update_xaddrs = AsyncMock(return_value=True)
|
||||
mock_onvif_camera.create_devicemgmt_service = MagicMock(return_value=devicemgmt)
|
||||
mock_onvif_camera.create_media_service = MagicMock(return_value=media_service)
|
||||
mock_onvif_camera.create_devicemgmt_service = AsyncMock(return_value=devicemgmt)
|
||||
mock_onvif_camera.create_media_service = AsyncMock(return_value=media_service)
|
||||
mock_onvif_camera.close = AsyncMock(return_value=None)
|
||||
mock_onvif_camera.xaddrs = {}
|
||||
mock_onvif_camera.services = {}
|
||||
|
||||
def mock_constructor(
|
||||
host,
|
||||
|
||||
@@ -27,7 +27,7 @@ async def test_reboot_button(hass: HomeAssistant) -> None:
|
||||
async def test_reboot_button_press(hass: HomeAssistant) -> None:
|
||||
"""Test Reboot button press."""
|
||||
_, camera, _ = await setup_onvif_integration(hass)
|
||||
devicemgmt = camera.create_devicemgmt_service()
|
||||
devicemgmt = await camera.create_devicemgmt_service()
|
||||
devicemgmt.SystemReboot = AsyncMock(return_value=True)
|
||||
|
||||
await hass.services.async_call(
|
||||
|
||||
@@ -5,7 +5,7 @@ from homeassistant import config_entries, data_entry_flow
|
||||
from homeassistant.components import dhcp
|
||||
from homeassistant.components.onvif import DOMAIN, config_flow
|
||||
from homeassistant.config_entries import SOURCE_DHCP
|
||||
from homeassistant.const import CONF_HOST
|
||||
from homeassistant.const import CONF_HOST, CONF_USERNAME
|
||||
from homeassistant.core import HomeAssistant
|
||||
from homeassistant.data_entry_flow import FlowResultType
|
||||
from homeassistant.helpers import device_registry as dr
|
||||
@@ -710,6 +710,14 @@ async def test_discovered_by_dhcp_does_not_update_if_no_matching_entry(
|
||||
assert result["reason"] == "no_devices_found"
|
||||
|
||||
|
||||
def _get_schema_default(schema, key_name):
|
||||
"""Iterate schema to find a key."""
|
||||
for schema_key in schema:
|
||||
if schema_key == key_name:
|
||||
return schema_key.default()
|
||||
raise KeyError(f"{key_name} not found in schema")
|
||||
|
||||
|
||||
async def test_form_reauth(hass: HomeAssistant) -> None:
|
||||
"""Test reauthenticate."""
|
||||
entry, _, _ = await setup_onvif_integration(hass)
|
||||
@@ -721,6 +729,10 @@ async def test_form_reauth(hass: HomeAssistant) -> None:
|
||||
)
|
||||
assert result["type"] == FlowResultType.FORM
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
assert (
|
||||
_get_schema_default(result["data_schema"].schema, CONF_USERNAME)
|
||||
== entry.data[CONF_USERNAME]
|
||||
)
|
||||
|
||||
with patch(
|
||||
"homeassistant.components.onvif.config_flow.get_device"
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
"""Test ONVIF diagnostics."""
|
||||
from unittest.mock import ANY
|
||||
|
||||
from homeassistant.core import HomeAssistant
|
||||
|
||||
from . import (
|
||||
@@ -71,6 +73,8 @@ async def test_diagnostics(
|
||||
"video_source_token": None,
|
||||
}
|
||||
],
|
||||
"services": ANY,
|
||||
"xaddrs": ANY,
|
||||
},
|
||||
"events": {
|
||||
"pullpoint_manager_state": {
|
||||
|
||||
@@ -7,6 +7,7 @@ from roborock.containers import (
|
||||
Consumable,
|
||||
DNDTimer,
|
||||
HomeData,
|
||||
NetworkInfo,
|
||||
Status,
|
||||
UserData,
|
||||
)
|
||||
@@ -368,3 +369,7 @@ STATUS = Status.from_dict(
|
||||
)
|
||||
|
||||
PROP = DeviceProp(STATUS, DND_TIMER, CLEAN_SUMMARY, CONSUMABLE, CLEAN_RECORD)
|
||||
|
||||
NETWORK_INFO = NetworkInfo(
|
||||
ip="123.232.12.1", ssid="wifi", mac="ac:cc:cc:cc:cc", bssid="bssid", rssi=90
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Test for Roborock init."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from roborock.exceptions import RoborockTimeout
|
||||
|
||||
from homeassistant.components.roborock.const import DOMAIN
|
||||
from homeassistant.config_entries import ConfigEntryState
|
||||
from homeassistant.core import HomeAssistant
|
||||
@@ -8,6 +10,7 @@ from homeassistant.helpers.update_coordinator import UpdateFailed
|
||||
from homeassistant.setup import async_setup_component
|
||||
|
||||
from tests.common import MockConfigEntry
|
||||
from tests.components.roborock.mock_data import HOME_DATA, NETWORK_INFO
|
||||
|
||||
|
||||
async def test_unload_entry(
|
||||
@@ -38,3 +41,23 @@ async def test_config_entry_not_ready(
|
||||
):
|
||||
await async_setup_component(hass, DOMAIN, {})
|
||||
assert mock_roborock_entry.state is ConfigEntryState.SETUP_RETRY
|
||||
|
||||
|
||||
async def test_continue_setup_mqtt_disconnect_fail(
|
||||
hass: HomeAssistant, mock_roborock_entry: MockConfigEntry
|
||||
):
|
||||
"""Test that if disconnect fails, we still continue setting up."""
|
||||
with patch(
|
||||
"homeassistant.components.roborock.RoborockApiClient.get_home_data",
|
||||
return_value=HOME_DATA,
|
||||
), patch(
|
||||
"homeassistant.components.roborock.RoborockMqttClient.get_networking",
|
||||
return_value=NETWORK_INFO,
|
||||
), patch(
|
||||
"homeassistant.components.roborock.RoborockMqttClient.async_disconnect",
|
||||
side_effect=RoborockTimeout(),
|
||||
), patch(
|
||||
"homeassistant.components.roborock.RoborockDataUpdateCoordinator.async_config_entry_first_refresh"
|
||||
):
|
||||
await async_setup_component(hass, DOMAIN, {})
|
||||
assert mock_roborock_entry.state is ConfigEntryState.LOADED
|
||||
|
||||
@@ -102,7 +102,7 @@ INDEXED_SENSOR_CONFIG_2 = {
|
||||
}
|
||||
|
||||
|
||||
NESTED_SENSOR_CONFIG = {
|
||||
NESTED_SENSOR_CONFIG_1 = {
|
||||
"sn": {
|
||||
"Time": "2020-03-03T00:00:00+00:00",
|
||||
"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(
|
||||
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
|
||||
@@ -174,12 +185,59 @@ async def test_controlling_state_via_mqtt(
|
||||
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(
|
||||
hass: HomeAssistant, mqtt_mock: MqttMockHAClient, setup_tasmota
|
||||
hass: HomeAssistant,
|
||||
mqtt_mock: MqttMockHAClient,
|
||||
setup_tasmota,
|
||||
sensor_config,
|
||||
entity_ids,
|
||||
messages,
|
||||
states,
|
||||
) -> None:
|
||||
"""Test state update via MQTT."""
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
sensor_config = copy.deepcopy(NESTED_SENSOR_CONFIG)
|
||||
sensor_config = copy.deepcopy(sensor_config)
|
||||
mac = config["mac"]
|
||||
|
||||
async_fire_mqtt_message(
|
||||
@@ -195,31 +253,29 @@ async def test_nested_sensor_state_via_mqtt(
|
||||
)
|
||||
await hass.async_block_till_done()
|
||||
|
||||
state = hass.states.get("sensor.tasmota_tx23_speed_act")
|
||||
assert state.state == "unavailable"
|
||||
assert not state.attributes.get(ATTR_ASSUMED_STATE)
|
||||
for entity_id in entity_ids:
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == "unavailable"
|
||||
assert not state.attributes.get(ATTR_ASSUMED_STATE)
|
||||
|
||||
async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/LWT", "Online")
|
||||
await hass.async_block_till_done()
|
||||
state = hass.states.get("sensor.tasmota_tx23_speed_act")
|
||||
assert state.state == STATE_UNKNOWN
|
||||
assert not state.attributes.get(ATTR_ASSUMED_STATE)
|
||||
for entity_id in entity_ids:
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == STATE_UNKNOWN
|
||||
assert not state.attributes.get(ATTR_ASSUMED_STATE)
|
||||
|
||||
# Test periodic state update
|
||||
async_fire_mqtt_message(
|
||||
hass, "tasmota_49A3BC/tele/SENSOR", '{"TX23":{"Speed":{"Act":"12.3"}}}'
|
||||
)
|
||||
state = hass.states.get("sensor.tasmota_tx23_speed_act")
|
||||
assert state.state == "12.3"
|
||||
async_fire_mqtt_message(hass, "tasmota_49A3BC/tele/SENSOR", messages[0])
|
||||
for entity_id in entity_ids:
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == states[0][entity_id]
|
||||
|
||||
# Test polled state update
|
||||
async_fire_mqtt_message(
|
||||
hass,
|
||||
"tasmota_49A3BC/stat/STATUS10",
|
||||
'{"StatusSNS":{"TX23":{"Speed":{"Act":"23.4"}}}}',
|
||||
)
|
||||
state = hass.states.get("sensor.tasmota_tx23_speed_act")
|
||||
assert state.state == "23.4"
|
||||
async_fire_mqtt_message(hass, "tasmota_49A3BC/stat/STATUS10", messages[1])
|
||||
for entity_id in entity_ids:
|
||||
state = hass.states.get(entity_id)
|
||||
assert state.state == states[1][entity_id]
|
||||
|
||||
|
||||
async def test_indexed_sensor_state_via_mqtt(
|
||||
@@ -728,7 +784,7 @@ async def test_nested_sensor_attributes(
|
||||
) -> None:
|
||||
"""Test correct attributes for sensors."""
|
||||
config = copy.deepcopy(DEFAULT_CONFIG)
|
||||
sensor_config = copy.deepcopy(NESTED_SENSOR_CONFIG)
|
||||
sensor_config = copy.deepcopy(NESTED_SENSOR_CONFIG_1)
|
||||
mac = config["mac"]
|
||||
|
||||
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("friendly_name") == "Tasmota TX23 Dir Avg"
|
||||
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(
|
||||
|
||||
@@ -2,7 +2,11 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
from transmission_rpc.error import TransmissionError
|
||||
from transmission_rpc.error import (
|
||||
TransmissionAuthError,
|
||||
TransmissionConnectError,
|
||||
TransmissionError,
|
||||
)
|
||||
|
||||
from homeassistant import config_entries
|
||||
from homeassistant.components import transmission
|
||||
@@ -125,7 +129,7 @@ async def test_error_on_wrong_credentials(
|
||||
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(
|
||||
result["flow_id"],
|
||||
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(
|
||||
hass: HomeAssistant, mock_api: MagicMock
|
||||
) -> None:
|
||||
@@ -145,7 +164,7 @@ async def test_error_on_connection_failure(
|
||||
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(
|
||||
result["flow_id"],
|
||||
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["description_placeholders"] == {"username": "user"}
|
||||
|
||||
mock_api.side_effect = TransmissionError("401: Unauthorized")
|
||||
mock_api.side_effect = TransmissionAuthError()
|
||||
result2 = await hass.config_entries.flow.async_configure(
|
||||
result["flow_id"],
|
||||
{
|
||||
@@ -248,7 +267,7 @@ async def test_reauth_failed_connection_error(
|
||||
assert result["step_id"] == "reauth_confirm"
|
||||
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(
|
||||
result["flow_id"],
|
||||
{
|
||||
|
||||
@@ -3,7 +3,11 @@
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
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.config_entries import ConfigEntryState
|
||||
@@ -40,7 +44,7 @@ async def test_setup_failed_connection_error(
|
||||
entry = MockConfigEntry(domain=DOMAIN, data=MOCK_CONFIG_DATA)
|
||||
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)
|
||||
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.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)
|
||||
assert entry.state == ConfigEntryState.SETUP_ERROR
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
"""Test ZHA Core cluster handlers."""
|
||||
import asyncio
|
||||
from collections.abc import Callable
|
||||
import logging
|
||||
import math
|
||||
from unittest import mock
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
import zigpy.device
|
||||
import zigpy.endpoint
|
||||
from zigpy.endpoint import Endpoint as ZigpyEndpoint
|
||||
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
|
||||
|
||||
@@ -531,6 +531,41 @@ async def test_async_parallel_updates_with_two(hass: HomeAssistant) -> None:
|
||||
test_lock.release()
|
||||
|
||||
|
||||
async def test_async_parallel_updates_with_one_using_executor(
|
||||
hass: HomeAssistant,
|
||||
) -> None:
|
||||
"""Test parallel updates with 1 (sequential) using the executor."""
|
||||
test_semaphore = asyncio.Semaphore(1)
|
||||
locked = []
|
||||
|
||||
class SyncEntity(entity.Entity):
|
||||
"""Test entity."""
|
||||
|
||||
def __init__(self, entity_id):
|
||||
"""Initialize sync test entity."""
|
||||
self.entity_id = entity_id
|
||||
self.hass = hass
|
||||
self.parallel_updates = test_semaphore
|
||||
|
||||
def update(self):
|
||||
"""Test update."""
|
||||
locked.append(self.parallel_updates.locked())
|
||||
|
||||
entities = [SyncEntity(f"sensor.test_{i}") for i in range(3)]
|
||||
|
||||
await asyncio.gather(
|
||||
*[
|
||||
hass.async_create_task(
|
||||
ent.async_update_ha_state(True),
|
||||
f"Entity schedule update ha state {ent.entity_id}",
|
||||
)
|
||||
for ent in entities
|
||||
]
|
||||
)
|
||||
|
||||
assert locked == [True, True, True]
|
||||
|
||||
|
||||
async def test_async_remove_no_platform(hass: HomeAssistant) -> None:
|
||||
"""Test async_remove method when no platform set."""
|
||||
ent = entity.Entity()
|
||||
|
||||
@@ -190,3 +190,39 @@ def test_sr_latn() -> None:
|
||||
"sr-CS",
|
||||
"sr-RS",
|
||||
]
|
||||
|
||||
|
||||
def test_no_nb_same() -> None:
|
||||
"""Test that the no/nb are interchangeable."""
|
||||
assert language.matches(
|
||||
"no",
|
||||
["en-US", "en-GB", "nb"],
|
||||
) == ["nb"]
|
||||
assert language.matches(
|
||||
"nb",
|
||||
["en-US", "en-GB", "no"],
|
||||
) == ["no"]
|
||||
|
||||
|
||||
def test_no_nb_prefer_exact() -> None:
|
||||
"""Test that the exact language is preferred even if an interchangeable language is available."""
|
||||
assert language.matches(
|
||||
"no",
|
||||
["en-US", "en-GB", "nb", "no"],
|
||||
) == ["no", "nb"]
|
||||
assert language.matches(
|
||||
"no",
|
||||
["en-US", "en-GB", "no", "nb"],
|
||||
) == ["no", "nb"]
|
||||
|
||||
|
||||
def test_no_nb_prefer_exact_regions() -> None:
|
||||
"""Test that the exact language/region is preferred."""
|
||||
assert language.matches(
|
||||
"no-AA",
|
||||
["en-US", "en-GB", "nb-AA", "no-AA"],
|
||||
) == ["no-AA", "nb-AA"]
|
||||
assert language.matches(
|
||||
"no-AA",
|
||||
["en-US", "en-GB", "no-AA", "nb-AA"],
|
||||
) == ["no-AA", "nb-AA"]
|
||||
|
||||
Reference in New Issue
Block a user