Compare commits

...

67 Commits

Author SHA1 Message Date
Paulus Schoutsen
e0a97ec90d 2023.5.3 (#93066) 2023-05-14 13:00:18 -04:00
Paulus Schoutsen
1f6a601fc9 Bumped version to 2023.5.3 2023-05-14 12:11:32 -04:00
Aaron Bach
ff14277805 Fix a series of bugs due to Notion API changes (#93039)
* Fix a series of bugs due to Notion API changes

* Simplify

* Reduce blast radius

* Reduce blast radius

* Fix tests
2023-05-14 12:11:22 -04:00
J. Nick Koston
6424dee231 Fix sslv2/sslv3 with unverified connections (#93037)
In #90191 we use the same ssl context for httpx now to avoid
a memory leak, but httpx previously allowed sslv2/sslv3 for
unverified connections

This reverts to the behavior before #90191
2023-05-14 12:11:21 -04:00
J. Nick Koston
13c51e9c34 Disable cleanup_closed for aiohttp.TCPConnector with cpython 3.11.1+ (#93013)
* Disable cleanup_closed for aiohttp.TCPConnector with cpython 3.11.2+

There is currently a relatively fast memory leak when using
cpython 3.11.2+ and cleanup_closed with aiohttp

For my production instance it was leaking ~450MiB per day
of `MemoryBIO`, `SSLProtocol`, `SSLObject`, `_SSLProtocolTransport`
`memoryview`, and `managedbuffer` objects

see https://github.com/aio-libs/aiohttp/issues/7252
see https://github.com/python/cpython/pull/98540

* Update homeassistant/helpers/aiohttp_client.py
2023-05-14 12:11:20 -04:00
puddly
304c34a119 Bump bellows to 0.35.5 to fix Aqara Zigbee connectivity issue (#92999)
Bump bellows to 0.35.5
2023-05-14 12:11:19 -04:00
starkillerOG
d840d27f2d Bump reolink-aio to 0.5.15 (#92979) 2023-05-14 12:11:18 -04:00
Michael
a8cf3fadaa Fix remove of device when surveillance station is not used in Synology DSM (#92957) 2023-05-14 12:11:17 -04:00
Joost Lekkerkerker
a3f3b43c20 Bump python-vehicle to 1.0.1 (#92933) 2023-05-14 12:11:17 -04:00
Robert Hillis
b0520ccb94 Bump eternalegypt to 0.0.16 (#92919) 2023-05-14 12:11:16 -04:00
Jonathan Keslin
fe308e26dc Bump volvooncall to 0.10.3 to fix sensor type error (#92913) 2023-05-14 12:11:15 -04:00
Michael
60fb71159d Fix uptime sensor deviation detection in Fritz!Tools (#92907) 2023-05-14 12:11:14 -04:00
G Johansson
413dbe89e5 Fix already_configured string in workday (#92901)
* Fix already_configured string in workday

* Fix strings
2023-05-14 12:11:13 -04:00
J. Nick Koston
7abe9f1f9a Bump bluetooth-auto-recovery to 1.2.0 (#92893) 2023-05-14 12:11:12 -04:00
Glenn Waters
252b99f00b Bump UPB integration library to 0.5.4 (#92879) 2023-05-14 12:11:11 -04:00
J. Nick Koston
8e407334b7 Add ONVIF services to diagnostics (#92878) 2023-05-14 12:11:10 -04:00
puddly
91faa31e8c Bump ZHA dependencies (#92870) 2023-05-14 12:11:09 -04:00
Michael Hansen
5e77de35bd Allow "no" to match "nb" in language util (#92862)
* Allow "no" to match "nb"

* Adjust comparison for speed
2023-05-14 12:11:09 -04:00
jjlawren
c1b18dcbba Bump sonos-websocket to 0.1.1 (#92834) 2023-05-14 12:11:08 -04:00
Diogo Gomes
3c45bda0e8 Don't try to restore unavailable nor unknown states (#92825) 2023-05-14 12:11:07 -04:00
Álvaro Fernández Rojas
7361c29cba Update aioairzone to v0.5.5 (#92812) 2023-05-14 12:11:06 -04:00
Álvaro Fernández Rojas
a551de06c7 Fix Airzone Auto operation mode (#92796) 2023-05-14 12:11:05 -04:00
Erik Montnemery
84ce2f13f2 Fix race in Alexa async_enable_proactive_mode (#92785) 2023-05-14 12:11:04 -04:00
Álvaro Fernández Rojas
5c949bd862 Update aioairzone to v0.5.3 (#92780) 2023-05-14 12:11:03 -04:00
Keilin Bickar
16020d8ab9 Bump asyncsleepiq to 1.3.5 (#92759) 2023-05-14 12:11:02 -04:00
karwosts
f866d6100d Fix zwave_js services example data (#92748) 2023-05-14 12:11:01 -04:00
Brandon Rothweiler
8d0da78fab Increase timeout to 30 seconds for Mazda integration (#92744) 2023-05-14 12:11:00 -04:00
J. Nick Koston
7173a4f377 Bump aioesphomeapi to 3.7.4 to fix proxied BLE connections not retrying right away on error (#92741) 2023-05-14 12:11:00 -04:00
Eduard van Valkenburg
d4acb2a381 Update deprecated functions in SIA (#92737)
update deprecated functions
2023-05-14 12:10:59 -04:00
Shay Levy
b1111eb2c7 Bump aiowebostv to 0.3.3 to fix Python 3.11 support (#92736)
Bump aiowebostv to 0.3.3
2023-05-14 12:10:58 -04:00
Mick Vleeshouwer
4895ca218f Bump pyoverkiz to 1.7.8 (#92702) 2023-05-14 12:10:57 -04:00
Aaron Bach
91e9d21548 Bump aionotion to 2023.05.1 (#92697) 2023-05-14 12:10:56 -04:00
J. Nick Koston
996c6c4a92 Fix onvif reauth when device returns a http 401/403 error (#92690) 2023-05-14 12:10:55 -04:00
J. Nick Koston
96ff24aa2f Always request at least one zone for multi-zone LIFX devices (#92683) 2023-05-14 12:08:33 -04:00
J. Nick Koston
dcc5940f9b Fix parallel_updates being acquired too late for entity executor jobs (#92681)
* Fix parallel_updates being acquired too late for entity executor jobs

* tweak
2023-05-14 12:08:33 -04:00
rikroe
dd51bba677 Bump bimmer_connected to 0.13.3 (#92648)
Co-authored-by: rikroe <rikroe@users.noreply.github.com>
2023-05-14 12:08:32 -04:00
Luke
ac9da5c167 Roborock continue on failed mqtt disconnect (#92502)
continue on async disconnect failure
2023-05-14 12:08:31 -04:00
Paulus Schoutsen
e904edb12e 2023.5.2 (#92610) 2023-05-05 15:23:51 -04:00
J. Nick Koston
ddebfb3ac5 Fix duplicate ONVIF sensors (#92629)
Some cameras do not configure the video source correctly
when using webhooks but work fine with PullPoint which
results in duplicate sensors
2023-05-05 14:41:00 -04:00
J. Nick Koston
fe57901b5f Add support for visitor detections to onvif (#92350) 2023-05-05 14:40:59 -04:00
J. Nick Koston
73d4c73dbb Fix missing ONVIF events when switching from PullPoint to webhooks (#92627)
We now let the PullPoint subscription expire instead of explicitly
unsubscribing when pausing the subscription. We will still unsubscribe
it if Home Assistant is shutdown or the integration is reloaded

Some cameras will cancel ALL subscriptions when we do an unsubscribe
so we want to let the PullPoint subscription expire instead
of explicitly cancelling it.
2023-05-05 14:39:32 -04:00
Bram Kragten
f1bccef224 Update frontend to 20230503.3 (#92617) 2023-05-05 14:39:31 -04:00
Joost Lekkerkerker
cf243fbe11 Lower scan interval for OpenSky (#92593)
* Lower scan interval for opensky to avoid hitting rate limit

* Lower scan interval for opensky to avoid hitting rate limit

* Update homeassistant/components/opensky/sensor.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

* Update homeassistant/components/opensky/sensor.py

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>

---------

Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2023-05-05 14:39:30 -04:00
J. Nick Koston
35c48d3d0e Improve reliability of ONVIF subscription renewals (#92551)
* Improve reliablity of onvif subscription renewals

upstream changelog: https://github.com/hunterjm/python-onvif-zeep-async/compare/v2.0.0...v2.1.0

* ```
Traceback (most recent call last):
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/onvif/client.py", line 75, in _async_wrap_connection_error_retry
    return await func(*args, **kwargs)
  File "/Users/bdraco/home-assistant/homeassistant/components/onvif/event.py", line 441, in _async_call_pullpoint_subscription_renew
    await self._pullpoint_subscription.Renew(SUBSCRIPTION_RELATIVE_TIME)
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/zeep/proxy.py", line 64, in __call__
    return await self._proxy._binding.send_async(
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/zeep/wsdl/bindings/soap.py", line 156, in send_async
    response = await client.transport.post_xml(
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/zeep/transports.py", line 235, in post_xml
    response = await self.post(address, message, headers)
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/zeep/transports.py", line 220, in post
    response = await self.client.post(
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_client.py", line 1845, in post
    return await self.request(
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_client.py", line 1530, in request
    return await self.send(request, auth=auth, follow_redirects=follow_redirects)
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_client.py", line 1617, in send
    response = await self._send_handling_auth(
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_client.py", line 1645, in _send_handling_auth
    response = await self._send_handling_redirects(
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_client.py", line 1682, in _send_handling_redirects
    response = await self._send_single_request(request)
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_client.py", line 1719, in _send_single_request
    response = await transport.handle_async_request(request)
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_transports/default.py", line 352, in handle_async_request
    with map_httpcore_exceptions():
  File "/opt/homebrew/Cellar/python@3.10/3.10.10_1/Frameworks/Python.framework/Versions/3.10/lib/python3.10/contextlib.py", line 153, in __exit__
    self.gen.throw(typ, value, traceback)
  File "/Users/bdraco/home-assistant/venv/lib/python3.10/site-packages/httpx/_transports/default.py", line 77, in map_httpcore_exceptions
    raise mapped_exc(message) from exc
httpx.ReadTimeout
```

* adjust timeouts for slower tplink cameras

* tweak

* more debug

* tweak

* adjust message

* tweak

* Revert "tweak"

This reverts commit 10ee2a8de70e93dc5be85b1992ec4d30c2188344.

* give time in seconds

* revert

* revert

* Update homeassistant/components/onvif/event.py

* Update homeassistant/components/onvif/event.py
2023-05-05 14:39:29 -04:00
Paulus Schoutsen
15ef53cd9a Bumped version to 2023.5.2 2023-05-05 08:47:12 -04:00
Erik Montnemery
fb29e1a14e Bump hatasmota to 0.6.5 (#92585)
* Bump hatasmota to 0.6.5

* Fix tests
2023-05-05 08:47:08 -04:00
epenet
f8c3586f6b Fix hassio get_os_info retry (#92569) 2023-05-05 08:47:07 -04:00
Paulus Schoutsen
e8808b5fe7 Re-run expose entities migration if first time failed (#92564)
* Re-run expose entities migration if first time failed

* Count number of exposed entities

* Add tests

---------

Co-authored-by: Erik <erik@montnemery.com>
2023-05-05 08:47:06 -04:00
J. Nick Koston
82c0967716 Bump elkm1-lib to 2.2.2 (#92560)
changelog: https://github.com/gwww/elkm1/compare/2.2.1...2.2.2

fixes #92467
2023-05-05 08:47:05 -04:00
J. Nick Koston
163823d2a5 Allow duplicate state updates when force_update is set on an esphome sensor (#92553)
* Allow duplicate states when force_update is set on an esphome sensor

fixes #91221

* Update homeassistant/components/esphome/entry_data.py

Co-authored-by: pdw-mb <pdw@mythic-beasts.com>

---------

Co-authored-by: pdw-mb <pdw@mythic-beasts.com>
2023-05-05 08:47:04 -04:00
puddly
2dd1ce2047 Handle invalid ZHA cluster handlers (#92543)
* Do not crash on startup when an invalid cluster handler is encountered

* Add a unit test
2023-05-05 08:47:03 -04:00
J. Nick Koston
241cacde62 Bump aioesphomeapi to 13.7.3 to fix disconnecting while handshake is in progress (#92537)
Bump aioesphomeapi to 13.7.3

fixes #92432
2023-05-05 08:47:02 -04:00
Erik Montnemery
8a11ee81c4 Improve cloud migration (#92520)
* Improve cloud migration

* Tweak

* Use entity_ids func

---------

Co-authored-by: Paulus Schoutsen <balloob@gmail.com>
2023-05-05 08:47:01 -04:00
J. Nick Koston
e3762724a3 Fix blocking I/O in the event loop when starting ONVIF (#92518) 2023-05-05 08:47:00 -04:00
karwosts
b973825833 Fix scene service examples (#92501) 2023-05-05 08:46:59 -04:00
Eduard van Valkenburg
b2fcbbe50e Fix for SIA Code not being handled well (#92469)
* updated sia requirements

* updates because of changes in package

* linting and other small fixes

* fix for unknown code

* added same to alarm_control_panel
2023-05-05 08:46:58 -04:00
Francesco Carnielli
d96b37a004 Fix power sensor state_class in Netatmo integration (#92468) 2023-05-05 08:46:57 -04:00
DDanii
affece8857 Fix transmission error handling (#91548)
* transmission error handle fix

* added unexpected case tests
2023-05-05 08:46:56 -04:00
Paulus Schoutsen
bce18bf61a 2023.5.1 (#92513) 2023-05-04 12:45:55 -04:00
Paulus Schoutsen
eda0731e60 Bumped version to 2023.5.1 2023-05-04 10:23:58 -04:00
Bram Kragten
238c87055f Update frontend to 20230503.2 (#92508) 2023-05-04 10:23:53 -04:00
Erik Montnemery
4b4464a3de Force migration of cloud settings to exposed_entities (#92499) 2023-05-04 10:23:52 -04:00
J. Nick Koston
a07fbdd61c Bump bluetooth-auto-recovery 1.1.2 (#92495)
Improve handling when getting the power state times out

https://github.com/Bluetooth-Devices/bluetooth-auto-recovery/compare/v1.1.1...v1.1.2
2023-05-04 10:23:52 -04:00
J. Nick Koston
3126ebe9d6 Fix lifx light strips when color zones are not initially populated (#92487)
fixes #92456
2023-05-04 10:23:51 -04:00
Aaron Bach
89aec9d356 Bump aionotion to 2023.05.0 (#92451) 2023-05-04 10:23:49 -04:00
J. Nick Koston
0cfa566ff6 Fix onvif cameras with invalid encodings in device info (#92450)
Co-authored-by: epenet <6771947+epenet@users.noreply.github.com>
2023-05-04 10:23:49 -04:00
J. Nick Koston
fffece95f5 Fix onvif setup when time set service is not functional (#92447) 2023-05-04 10:23:48 -04:00
95 changed files with 1454 additions and 648 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,5 +7,5 @@
"integration_type": "hub",
"iot_class": "cloud_polling",
"loggers": ["aionotion"],
"requirements": ["aionotion==2023.04.2"]
"requirements": ["aionotion==2023.05.4"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"quality_scale": "platinum",
"requirements": ["vehicle==1.0.0"]
"requirements": ["vehicle==1.0.1"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -8,5 +8,5 @@
"iot_class": "local_push",
"loggers": ["hatasmota"],
"mqtt": ["tasmota/discovery/#"],
"requirements": ["hatasmota==0.6.4"]
"requirements": ["hatasmota==0.6.5"]
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View 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
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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