This commit is contained in:
Franck Nijhof 2024-01-19 20:21:12 +01:00 committed by GitHub
commit 6e6a5ff52c
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
52 changed files with 686 additions and 306 deletions

View file

@ -5,5 +5,5 @@
"documentation": "https://www.home-assistant.io/integrations/enigma2",
"iot_class": "local_polling",
"loggers": ["openwebif"],
"requirements": ["openwebifpy==4.0.4"]
"requirements": ["openwebifpy==4.2.1"]
}

View file

@ -6,7 +6,7 @@
"documentation": "https://www.home-assistant.io/integrations/enphase_envoy",
"iot_class": "local_polling",
"loggers": ["pyenphase"],
"requirements": ["pyenphase==1.15.2"],
"requirements": ["pyenphase==1.17.0"],
"zeroconf": [
{
"type": "_enphase-envoy._tcp.local."

View file

@ -6,5 +6,5 @@
"documentation": "https://www.home-assistant.io/integrations/flipr",
"iot_class": "cloud_polling",
"loggers": ["flipr_api"],
"requirements": ["flipr-api==1.5.0"]
"requirements": ["flipr-api==1.5.1"]
}

View file

@ -14,6 +14,6 @@
"documentation": "https://www.home-assistant.io/integrations/homekit_controller",
"iot_class": "local_push",
"loggers": ["aiohomekit", "commentjson"],
"requirements": ["aiohomekit==3.1.2"],
"requirements": ["aiohomekit==3.1.3"],
"zeroconf": ["_hap._tcp.local.", "_hap._udp.local."]
}

View file

@ -223,9 +223,12 @@ class MatrixBot:
def _load_commands(self, commands: list[ConfigCommand]) -> None:
for command in commands:
# Set the command for all listening_rooms, unless otherwise specified.
command.setdefault(CONF_ROOMS, list(self._listening_rooms.values()))
if rooms := command.get(CONF_ROOMS):
command[CONF_ROOMS] = [self._listening_rooms[room] for room in rooms]
else:
command[CONF_ROOMS] = list(self._listening_rooms.values())
# COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_expression are set.
# COMMAND_SCHEMA guarantees that exactly one of CONF_WORD and CONF_EXPRESSION are set.
if (word_command := command.get(CONF_WORD)) is not None:
for room_id in command[CONF_ROOMS]:
self._word_commands.setdefault(room_id, {})

View file

@ -89,6 +89,10 @@ class MatterLight(MatterEntity, LightEntity):
colorY=int(matter_xy[1]),
# It's required in TLV. We don't implement transition time yet.
transitionTime=0,
# allow setting the color while the light is off,
# by setting the optionsMask to 1 (=ExecuteIfOff)
optionsMask=1,
optionsOverride=1,
)
)
@ -103,6 +107,10 @@ class MatterLight(MatterEntity, LightEntity):
saturation=int(matter_hs[1]),
# It's required in TLV. We don't implement transition time yet.
transitionTime=0,
# allow setting the color while the light is off,
# by setting the optionsMask to 1 (=ExecuteIfOff)
optionsMask=1,
optionsOverride=1,
)
)
@ -114,6 +122,10 @@ class MatterLight(MatterEntity, LightEntity):
colorTemperatureMireds=color_temp,
# It's required in TLV. We don't implement transition time yet.
transitionTime=0,
# allow setting the color while the light is off,
# by setting the optionsMask to 1 (=ExecuteIfOff)
optionsMask=1,
optionsOverride=1,
)
)

View file

@ -1295,7 +1295,7 @@ async def websocket_browse_media(
connection.send_error(msg["id"], "entity_not_found", "Entity not found")
return
if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features:
if MediaPlayerEntityFeature.BROWSE_MEDIA not in player.supported_features_compat:
connection.send_message(
websocket_api.error_message(
msg["id"], ERR_NOT_SUPPORTED, "Player does not support browsing media"

View file

@ -54,7 +54,7 @@ def async_get_schema(
if show_name:
schema = {
vol.Optional(CONF_NAME, default=defaults.get(CONF_NAME)): str,
vol.Required(CONF_NAME, default=defaults.get(CONF_NAME)): str,
**schema,
}

View file

@ -5,7 +5,7 @@ import logging
from socket import timeout
from typing import Any
from motionblinds import ParseException
from motionblinds import DEVICE_TYPES_WIFI, ParseException
from homeassistant.core import HomeAssistant
from homeassistant.helpers.update_coordinator import DataUpdateCoordinator
@ -59,7 +59,9 @@ class DataUpdateCoordinatorMotionBlinds(DataUpdateCoordinator):
def update_blind(self, blind):
"""Fetch data from a blind."""
try:
if self._wait_for_push:
if blind.device_type in DEVICE_TYPES_WIFI:
blind.Update_from_cache()
elif self._wait_for_push:
blind.Update()
else:
blind.Update_trigger()

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.8.6"]
"requirements": ["reolink-aio==0.8.7"]
}

View file

@ -7,5 +7,5 @@
"integration_type": "service",
"iot_class": "cloud_polling",
"loggers": ["aioridwell"],
"requirements": ["aioridwell==2023.07.0"]
"requirements": ["aioridwell==2024.01.0"]
}

View file

@ -30,12 +30,15 @@ from homeassistant.helpers.device_registry import (
from homeassistant.helpers.typing import ConfigType
from .const import (
BLOCK_EXPECTED_SLEEP_PERIOD,
BLOCK_WRONG_SLEEP_PERIOD,
CONF_COAP_PORT,
CONF_SLEEP_PERIOD,
DATA_CONFIG_ENTRY,
DEFAULT_COAP_PORT,
DOMAIN,
LOGGER,
MODELS_WITH_WRONG_SLEEP_PERIOD,
PUSH_UPDATE_ISSUE_ID,
)
from .coordinator import (
@ -162,6 +165,22 @@ async def _async_setup_block_entry(hass: HomeAssistant, entry: ConfigEntry) -> b
sleep_period = entry.data.get(CONF_SLEEP_PERIOD)
shelly_entry_data = get_entry_data(hass)[entry.entry_id]
# Some old firmware have a wrong sleep period hardcoded value.
# Following code block will force the right value for affected devices
if (
sleep_period == BLOCK_WRONG_SLEEP_PERIOD
and entry.data["model"] in MODELS_WITH_WRONG_SLEEP_PERIOD
):
LOGGER.warning(
"Updating stored sleep period for %s: from %s to %s",
entry.title,
sleep_period,
BLOCK_EXPECTED_SLEEP_PERIOD,
)
data = {**entry.data}
data[CONF_SLEEP_PERIOD] = sleep_period = BLOCK_EXPECTED_SLEEP_PERIOD
hass.config_entries.async_update_entry(entry, data=data)
async def _async_block_device_setup() -> None:
"""Set up a block based device that is online."""
shelly_entry_data.block = ShellyBlockCoordinator(hass, entry, device)

View file

@ -15,7 +15,6 @@ from homeassistant.config_entries import ConfigEntry
from homeassistant.const import STATE_ON, EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.restore_state import RestoreEntity
from .const import CONF_SLEEP_PERIOD
@ -210,16 +209,6 @@ RPC_SENSORS: Final = {
}
def _build_block_description(entry: RegistryEntry) -> BlockBinarySensorDescription:
"""Build description when restoring block attribute entities."""
return BlockBinarySensorDescription(
key="",
name="",
icon=entry.original_icon,
device_class=entry.original_device_class,
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@ -248,7 +237,6 @@ async def async_setup_entry(
async_add_entities,
SENSORS,
BlockSleepingBinarySensor,
_build_block_description,
)
else:
async_setup_entry_attribute_entities(
@ -257,7 +245,6 @@ async def async_setup_entry(
async_add_entities,
SENSORS,
BlockBinarySensor,
_build_block_description,
)
async_setup_entry_rest(
hass,

View file

@ -316,6 +316,21 @@ class BlockSleepingClimate(
"""Set new target temperature."""
if (current_temp := kwargs.get(ATTR_TEMPERATURE)) is None:
return
# Shelly TRV accepts target_t in Fahrenheit or Celsius, but you must
# send the units that the device expects
if self.block is not None and self.block.channel is not None:
therm = self.coordinator.device.settings["thermostats"][
int(self.block.channel)
]
LOGGER.debug("Themostat settings: %s", therm)
if therm.get("target_t", {}).get("units", "C") == "F":
current_temp = TemperatureConverter.convert(
cast(float, current_temp),
UnitOfTemperature.CELSIUS,
UnitOfTemperature.FAHRENHEIT,
)
await self.set_state_full_path(target_t_enabled=1, target_t=f"{current_temp}")
async def async_set_hvac_mode(self, hvac_mode: HVACMode) -> None:

View file

@ -14,7 +14,10 @@ from aioshelly.const import (
MODEL_DIMMER,
MODEL_DIMMER_2,
MODEL_DUO,
MODEL_DW,
MODEL_DW_2,
MODEL_GAS,
MODEL_HT,
MODEL_MOTION,
MODEL_MOTION_2,
MODEL_RGBW2,
@ -55,6 +58,12 @@ MODELS_SUPPORTING_LIGHT_EFFECTS: Final = (
MODEL_RGBW2,
)
MODELS_WITH_WRONG_SLEEP_PERIOD: Final = (
MODEL_DW,
MODEL_DW_2,
MODEL_HT,
)
# Bulbs that support white & color modes
DUAL_MODE_LIGHT_MODELS: Final = (
MODEL_BULB,
@ -176,6 +185,10 @@ KELVIN_MAX_VALUE: Final = 6500
KELVIN_MIN_VALUE_WHITE: Final = 2700
KELVIN_MIN_VALUE_COLOR: Final = 3000
# Sleep period
BLOCK_WRONG_SLEEP_PERIOD = 21600
BLOCK_EXPECTED_SLEEP_PERIOD = 43200
UPTIME_DEVIATION: Final = 5
# Time to wait before reloading entry upon device config change

View file

@ -39,7 +39,6 @@ def async_setup_entry_attribute_entities(
async_add_entities: AddEntitiesCallback,
sensors: Mapping[tuple[str, str], BlockEntityDescription],
sensor_class: Callable,
description_class: Callable[[RegistryEntry], BlockEntityDescription],
) -> None:
"""Set up entities for attributes."""
coordinator = get_entry_data(hass)[config_entry.entry_id].block
@ -56,7 +55,6 @@ def async_setup_entry_attribute_entities(
coordinator,
sensors,
sensor_class,
description_class,
)
@ -113,7 +111,6 @@ def async_restore_block_attribute_entities(
coordinator: ShellyBlockCoordinator,
sensors: Mapping[tuple[str, str], BlockEntityDescription],
sensor_class: Callable,
description_class: Callable[[RegistryEntry], BlockEntityDescription],
) -> None:
"""Restore block attributes entities."""
entities = []
@ -128,11 +125,12 @@ def async_restore_block_attribute_entities(
continue
attribute = entry.unique_id.split("-")[-1]
description = description_class(entry)
block_type = entry.unique_id.split("-")[-2].split("_")[0]
entities.append(
sensor_class(coordinator, None, attribute, description, entry, sensors)
)
if description := sensors.get((block_type, attribute)):
entities.append(
sensor_class(coordinator, None, attribute, description, entry)
)
if not entities:
return
@ -444,7 +442,7 @@ class ShellyBlockAttributeEntity(ShellyBlockEntity, Entity):
"""Available."""
available = super().available
if not available or not self.entity_description.available:
if not available or not self.entity_description.available or self.block is None:
return available
return self.entity_description.available(self.block)
@ -559,10 +557,8 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity):
attribute: str,
description: BlockEntityDescription,
entry: RegistryEntry | None = None,
sensors: Mapping[tuple[str, str], BlockEntityDescription] | None = None,
) -> None:
"""Initialize the sleeping sensor."""
self.sensors = sensors
self.last_state: State | None = None
self.coordinator = coordinator
self.attribute = attribute
@ -587,11 +583,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity):
@callback
def _update_callback(self) -> None:
"""Handle device update."""
if (
self.block is not None
or not self.coordinator.device.initialized
or self.sensors is None
):
if self.block is not None or not self.coordinator.device.initialized:
super()._update_callback()
return
@ -607,13 +599,7 @@ class ShellySleepingBlockAttributeEntity(ShellyBlockAttributeEntity):
if sensor_id != entity_sensor:
continue
description = self.sensors.get((block.type, sensor_id))
if description is None:
continue
self.block = block
self.entity_description = description
LOGGER.debug("Entity %s attached to block", self.name)
super()._update_callback()
return

View file

@ -1,7 +1,6 @@
"""Number for Shelly."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Any, Final, cast
@ -56,22 +55,6 @@ NUMBERS: Final = {
}
def _build_block_description(entry: RegistryEntry) -> BlockNumberDescription:
"""Build description when restoring block attribute entities."""
assert entry.capabilities
return BlockNumberDescription(
key="",
name="",
icon=entry.original_icon,
native_unit_of_measurement=entry.unit_of_measurement,
device_class=entry.original_device_class,
native_min_value=cast(float, entry.capabilities.get("min")),
native_max_value=cast(float, entry.capabilities.get("max")),
native_step=cast(float, entry.capabilities.get("step")),
mode=cast(NumberMode, entry.capabilities.get("mode")),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@ -85,7 +68,6 @@ async def async_setup_entry(
async_add_entities,
NUMBERS,
BlockSleepingNumber,
_build_block_description,
)
@ -101,11 +83,10 @@ class BlockSleepingNumber(ShellySleepingBlockAttributeEntity, RestoreNumber):
attribute: str,
description: BlockNumberDescription,
entry: RegistryEntry | None = None,
sensors: Mapping[tuple[str, str], BlockNumberDescription] | None = None,
) -> None:
"""Initialize the sleeping sensor."""
self.restored_data: NumberExtraStoredData | None = None
super().__init__(coordinator, block, attribute, description, entry, sensors)
super().__init__(coordinator, block, attribute, description, entry)
async def async_added_to_hass(self) -> None:
"""Handle entity which will be added."""

View file

@ -1,7 +1,6 @@
"""Sensor for Shelly."""
from __future__ import annotations
from collections.abc import Mapping
from dataclasses import dataclass
from typing import Final, cast
@ -36,7 +35,6 @@ from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from homeassistant.helpers.entity_registry import RegistryEntry
from homeassistant.helpers.typing import StateType
from homeassistant.util.enum import try_parse_enum
from .const import CONF_SLEEP_PERIOD, SHAIR_MAX_WORK_HOURS
from .coordinator import ShellyBlockCoordinator, ShellyRpcCoordinator
@ -963,17 +961,6 @@ RPC_SENSORS: Final = {
}
def _build_block_description(entry: RegistryEntry) -> BlockSensorDescription:
"""Build description when restoring block attribute entities."""
return BlockSensorDescription(
key="",
name="",
icon=entry.original_icon,
native_unit_of_measurement=entry.unit_of_measurement,
device_class=try_parse_enum(SensorDeviceClass, entry.original_device_class),
)
async def async_setup_entry(
hass: HomeAssistant,
config_entry: ConfigEntry,
@ -1002,7 +989,6 @@ async def async_setup_entry(
async_add_entities,
SENSORS,
BlockSleepingSensor,
_build_block_description,
)
else:
async_setup_entry_attribute_entities(
@ -1011,7 +997,6 @@ async def async_setup_entry(
async_add_entities,
SENSORS,
BlockSensor,
_build_block_description,
)
async_setup_entry_rest(
hass, config_entry, async_add_entities, REST_SENSORS, RestSensor
@ -1075,10 +1060,9 @@ class BlockSleepingSensor(ShellySleepingBlockAttributeEntity, RestoreSensor):
attribute: str,
description: BlockSensorDescription,
entry: RegistryEntry | None = None,
sensors: Mapping[tuple[str, str], BlockSensorDescription] | None = None,
) -> None:
"""Initialize the sleeping sensor."""
super().__init__(coordinator, block, attribute, description, entry, sensors)
super().__init__(coordinator, block, attribute, description, entry)
self.restored_data: SensorExtraStoredData | None = None
async def async_added_to_hass(self) -> None:

View file

@ -405,7 +405,7 @@ async def async_setup_entry(
is_enabled = check_legacy_resource(
f"{_type}_{argument}", legacy_resources
)
loaded_resources.add(f"{_type}_{slugify(argument)}")
loaded_resources.add(slugify(f"{_type}_{argument}"))
entities.append(
SystemMonitorSensor(
sensor_registry,
@ -425,7 +425,7 @@ async def async_setup_entry(
is_enabled = check_legacy_resource(
f"{_type}_{argument}", legacy_resources
)
loaded_resources.add(f"{_type}_{slugify(argument)}")
loaded_resources.add(slugify(f"{_type}_{argument}"))
entities.append(
SystemMonitorSensor(
sensor_registry,
@ -449,7 +449,7 @@ async def async_setup_entry(
sensor_registry[(_type, argument)] = SensorData(
argument, None, None, None, None
)
loaded_resources.add(f"{_type}_{slugify(argument)}")
loaded_resources.add(slugify(f"{_type}_{argument}"))
entities.append(
SystemMonitorSensor(
sensor_registry,

View file

@ -7,6 +7,8 @@ import psutil
_LOGGER = logging.getLogger(__name__)
SKIP_DISK_TYPES = {"proc", "tmpfs", "devtmpfs"}
def get_all_disk_mounts() -> set[str]:
"""Return all disk mount points on system."""
@ -18,6 +20,9 @@ def get_all_disk_mounts() -> set[str]:
# ENOENT, pop-up a Windows GUI error for a non-ready
# partition or just hang.
continue
if part.fstype in SKIP_DISK_TYPES:
# Ignore disks which are memory
continue
try:
usage = psutil.disk_usage(part.mountpoint)
except PermissionError:
@ -40,6 +45,9 @@ def get_all_network_interfaces() -> set[str]:
"""Return all network interfaces on system."""
interfaces: set[str] = set()
for interface, _ in psutil.net_if_addrs().items():
if interface.startswith("veth"):
# Don't load docker virtual network interfaces
continue
interfaces.add(interface)
_LOGGER.debug("Adding interfaces: %s", ", ".join(interfaces))
return interfaces

View file

@ -14,5 +14,5 @@
},
"iot_class": "cloud_polling",
"loggers": ["PyTado"],
"requirements": ["python-tado==0.17.3"]
"requirements": ["python-tado==0.17.4"]
}

View file

@ -14,7 +14,7 @@ from homeassistant.const import EntityCategory
from homeassistant.core import HomeAssistant
from homeassistant.helpers.entity_platform import AddEntitiesCallback
from .const import DOMAIN, TessieStatus
from .const import DOMAIN, TessieState
from .coordinator import TessieStateUpdateCoordinator
from .entity import TessieEntity
@ -30,7 +30,7 @@ DESCRIPTIONS: tuple[TessieBinarySensorEntityDescription, ...] = (
TessieBinarySensorEntityDescription(
key="state",
device_class=BinarySensorDeviceClass.CONNECTIVITY,
is_on=lambda x: x == TessieStatus.ONLINE,
is_on=lambda x: x == TessieState.ONLINE,
),
TessieBinarySensorEntityDescription(
key="charge_state_battery_heater_on",

View file

@ -13,13 +13,21 @@ MODELS = {
}
class TessieStatus(StrEnum):
class TessieState(StrEnum):
"""Tessie status."""
ASLEEP = "asleep"
ONLINE = "online"
class TessieStatus(StrEnum):
"""Tessie status."""
ASLEEP = "asleep"
AWAKE = "awake"
WAITING = "waiting_for_sleep"
class TessieSeatHeaterOptions(StrEnum):
"""Tessie seat heater options."""

View file

@ -5,7 +5,7 @@ import logging
from typing import Any
from aiohttp import ClientResponseError
from tessie_api import get_state
from tessie_api import get_state, get_status
from homeassistant.core import HomeAssistant
from homeassistant.exceptions import ConfigEntryAuthFailed
@ -45,11 +45,21 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
async def _async_update_data(self) -> dict[str, Any]:
"""Update vehicle data using Tessie API."""
try:
status = await get_status(
session=self.session,
api_key=self.api_key,
vin=self.vin,
)
if status["status"] == TessieStatus.ASLEEP:
# Vehicle is asleep, no need to poll for data
self.data["state"] = status["status"]
return self.data
vehicle = await get_state(
session=self.session,
api_key=self.api_key,
vin=self.vin,
use_cache=False,
use_cache=True,
)
except ClientResponseError as e:
if e.status == HTTPStatus.UNAUTHORIZED:
@ -57,13 +67,7 @@ class TessieStateUpdateCoordinator(DataUpdateCoordinator[dict[str, Any]]):
raise ConfigEntryAuthFailed from e
raise e
if vehicle["state"] == TessieStatus.ONLINE:
# Vehicle is online, all data is fresh
return self._flatten(vehicle)
# Vehicle is asleep, only update state
self.data["state"] = vehicle["state"]
return self.data
return self._flatten(vehicle)
def _flatten(
self, data: dict[str, Any], parent: str | None = None

View file

@ -7,6 +7,7 @@ import ssl
from types import MappingProxyType
from typing import Any, Literal
import aiohttp
from aiohttp import CookieJar
import aiounifi
from aiounifi.interfaces.api_handlers import ItemEvent
@ -374,7 +375,10 @@ class UniFiController:
async def _websocket_runner() -> None:
"""Start websocket."""
await self.api.start_websocket()
try:
await self.api.start_websocket()
except (aiohttp.ClientConnectorError, aiounifi.WebsocketError):
LOGGER.error("Websocket disconnected")
self.available = False
async_dispatcher_send(self.hass, self.signal_reachable)
self.hass.loop.call_later(RETRY_TIMER, self.reconnect, True)

View file

@ -8,7 +8,7 @@
"iot_class": "local_push",
"loggers": ["aiounifi"],
"quality_scale": "platinum",
"requirements": ["aiounifi==68"],
"requirements": ["aiounifi==69"],
"ssdp": [
{
"manufacturer": "Ubiquiti Networks",

View file

@ -42,7 +42,7 @@ from ..const import (
ZHA_CLUSTER_HANDLER_MSG_DATA,
ZHA_CLUSTER_HANDLER_READS_PER_REQ,
)
from ..helpers import LogMixin, retryable_req, safe_read
from ..helpers import LogMixin, safe_read
if TYPE_CHECKING:
from ..endpoint import Endpoint
@ -362,7 +362,6 @@ class ClusterHandler(LogMixin):
self.debug("skipping cluster handler configuration")
self._status = ClusterHandlerStatus.CONFIGURED
@retryable_req(delays=(1, 1, 3))
async def async_initialize(self, from_cache: bool) -> None:
"""Initialize cluster handler."""
if not from_cache and self._endpoint.device.skip_configuration:

View file

@ -592,12 +592,17 @@ class ZHADevice(LogMixin):
self.debug("started initialization")
await self._zdo_handler.async_initialize(from_cache)
self._zdo_handler.debug("'async_initialize' stage succeeded")
await asyncio.gather(
*(
endpoint.async_initialize(from_cache)
for endpoint in self._endpoints.values()
)
)
# We intentionally do not use `gather` here! This is so that if, for example,
# three `device.async_initialize()`s are spawned, only three concurrent requests
# will ever be in flight at once. Startup concurrency is managed at the device
# level.
for endpoint in self._endpoints.values():
try:
await endpoint.async_initialize(from_cache)
except Exception: # pylint: disable=broad-exception-caught
self.debug("Failed to initialize endpoint", exc_info=True)
self.debug("power source: %s", self.power_source)
self.status = DeviceStatus.INITIALIZED
self.debug("completed initialization")

View file

@ -2,7 +2,8 @@
from __future__ import annotations
import asyncio
from collections.abc import Callable
from collections.abc import Awaitable, Callable
import functools
import logging
from typing import TYPE_CHECKING, Any, Final, TypeVar
@ -11,6 +12,7 @@ from zigpy.typing import EndpointType as ZigpyEndpointType
from homeassistant.const import Platform
from homeassistant.core import callback
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.util.async_ import gather_with_limited_concurrency
from . import const, discovery, registries
from .cluster_handlers import ClusterHandler
@ -169,20 +171,32 @@ class Endpoint:
async def async_initialize(self, from_cache: bool = False) -> None:
"""Initialize claimed cluster handlers."""
await self._execute_handler_tasks("async_initialize", from_cache)
await self._execute_handler_tasks(
"async_initialize", from_cache, max_concurrency=1
)
async def async_configure(self) -> None:
"""Configure claimed cluster handlers."""
await self._execute_handler_tasks("async_configure")
async def _execute_handler_tasks(self, func_name: str, *args: Any) -> None:
async def _execute_handler_tasks(
self, func_name: str, *args: Any, max_concurrency: int | None = None
) -> None:
"""Add a throttled cluster handler task and swallow exceptions."""
cluster_handlers = [
*self.claimed_cluster_handlers.values(),
*self.client_cluster_handlers.values(),
]
tasks = [getattr(ch, func_name)(*args) for ch in cluster_handlers]
results = await asyncio.gather(*tasks, return_exceptions=True)
gather: Callable[..., Awaitable]
if max_concurrency is None:
gather = asyncio.gather
else:
gather = functools.partial(gather_with_limited_concurrency, max_concurrency)
results = await gather(*tasks, return_exceptions=True)
for cluster_handler, outcome in zip(cluster_handlers, results):
if isinstance(outcome, Exception):
cluster_handler.warning(

View file

@ -11,7 +11,7 @@ import itertools
import logging
import re
import time
from typing import TYPE_CHECKING, Any, NamedTuple, Self
from typing import TYPE_CHECKING, Any, NamedTuple, Self, cast
from zigpy.application import ControllerApplication
from zigpy.config import (
@ -36,6 +36,7 @@ from homeassistant.helpers import device_registry as dr, entity_registry as er
from homeassistant.helpers.device_registry import DeviceInfo
from homeassistant.helpers.dispatcher import async_dispatcher_send
from homeassistant.helpers.typing import ConfigType
from homeassistant.util.async_ import gather_with_limited_concurrency
from . import discovery
from .const import (
@ -142,7 +143,9 @@ class ZHAGateway:
self._log_relay_handler = LogRelayHandler(hass, self)
self.config_entry = config_entry
self._unsubs: list[Callable[[], None]] = []
self.shutting_down = False
self._reload_task: asyncio.Task | None = None
def get_application_controller_data(self) -> tuple[ControllerApplication, dict]:
"""Get an uninitialized instance of a zigpy `ControllerApplication`."""
@ -231,12 +234,17 @@ class ZHAGateway:
def connection_lost(self, exc: Exception) -> None:
"""Handle connection lost event."""
_LOGGER.debug("Connection to the radio was lost: %r", exc)
if self.shutting_down:
return
_LOGGER.debug("Connection to the radio was lost: %r", exc)
# Ensure we do not queue up multiple resets
if self._reload_task is not None:
_LOGGER.debug("Ignoring reset, one is already running")
return
self.hass.async_create_task(
self._reload_task = self.hass.async_create_task(
self.hass.config_entries.async_reload(self.config_entry.entry_id)
)
@ -285,6 +293,39 @@ class ZHAGateway:
# entity registry tied to the devices
discovery.GROUP_PROBE.discover_group_entities(zha_group)
@property
def radio_concurrency(self) -> int:
"""Maximum configured radio concurrency."""
return self.application_controller._concurrent_requests_semaphore.max_value # pylint: disable=protected-access
async def async_fetch_updated_state_mains(self) -> None:
"""Fetch updated state for mains powered devices."""
_LOGGER.debug("Fetching current state for mains powered devices")
now = time.time()
# Only delay startup to poll mains-powered devices that are online
online_devices = [
dev
for dev in self.devices.values()
if dev.is_mains_powered
and dev.last_seen is not None
and (now - dev.last_seen) < dev.consider_unavailable_time
]
# Prioritize devices that have recently been contacted
online_devices.sort(key=lambda dev: cast(float, dev.last_seen), reverse=True)
# Make sure that we always leave slots for non-startup requests
max_poll_concurrency = max(1, self.radio_concurrency - 4)
await gather_with_limited_concurrency(
max_poll_concurrency,
*(dev.async_initialize(from_cache=False) for dev in online_devices),
)
_LOGGER.debug("completed fetching current state for mains powered devices")
async def async_initialize_devices_and_entities(self) -> None:
"""Initialize devices and load entities."""
@ -295,17 +336,8 @@ class ZHAGateway:
async def fetch_updated_state() -> None:
"""Fetch updated state for mains powered devices."""
_LOGGER.debug("Fetching current state for mains powered devices")
await asyncio.gather(
*(
dev.async_initialize(from_cache=False)
for dev in self.devices.values()
if dev.is_mains_powered
)
)
_LOGGER.debug(
"completed fetching current state for mains powered devices - allowing polled requests"
)
await self.async_fetch_updated_state_mains()
_LOGGER.debug("Allowing polled requests")
self.hass.data[DATA_ZHA].allow_polling = True
# background the fetching of state for mains powered devices
@ -760,6 +792,10 @@ class ZHAGateway:
async def shutdown(self) -> None:
"""Stop ZHA Controller Application."""
if self.shutting_down:
_LOGGER.debug("Ignoring duplicate shutdown event")
return
_LOGGER.debug("Shutting down ZHA ControllerApplication")
self.shutting_down = True

View file

@ -5,17 +5,13 @@ https://home-assistant.io/integrations/zha/
"""
from __future__ import annotations
import asyncio
import binascii
import collections
from collections.abc import Callable, Iterator
import dataclasses
from dataclasses import dataclass
import enum
import functools
import itertools
import logging
from random import uniform
import re
from typing import TYPE_CHECKING, Any, TypeVar
@ -318,49 +314,6 @@ class LogMixin:
return self.log(logging.ERROR, msg, *args, **kwargs)
def retryable_req(
delays=(1, 5, 10, 15, 30, 60, 120, 180, 360, 600, 900, 1800), raise_=False
):
"""Make a method with ZCL requests retryable.
This adds delays keyword argument to function.
len(delays) is number of tries.
raise_ if the final attempt should raise the exception.
"""
def decorator(func):
@functools.wraps(func)
async def wrapper(cluster_handler, *args, **kwargs):
exceptions = (zigpy.exceptions.ZigbeeException, asyncio.TimeoutError)
try_count, errors = 1, []
for delay in itertools.chain(delays, [None]):
try:
return await func(cluster_handler, *args, **kwargs)
except exceptions as ex:
errors.append(ex)
if delay:
delay = uniform(delay * 0.75, delay * 1.25)
cluster_handler.debug(
"%s: retryable request #%d failed: %s. Retrying in %ss",
func.__name__,
try_count,
ex,
round(delay, 1),
)
try_count += 1
await asyncio.sleep(delay)
else:
cluster_handler.warning(
"%s: all attempts have failed: %s", func.__name__, errors
)
if raise_:
raise
return wrapper
return decorator
def convert_install_code(value: str) -> bytes:
"""Convert string to install code bytes and validate length."""

View file

@ -26,7 +26,7 @@
"pyserial-asyncio==0.6",
"zha-quirks==0.0.109",
"zigpy-deconz==0.22.4",
"zigpy==0.60.4",
"zigpy==0.60.6",
"zigpy-xbee==0.20.1",
"zigpy-zigate==0.12.0",
"zigpy-znp==0.12.1",

View file

@ -16,7 +16,7 @@ from .helpers.deprecation import (
APPLICATION_NAME: Final = "HomeAssistant"
MAJOR_VERSION: Final = 2024
MINOR_VERSION: Final = 1
PATCH_VERSION: Final = "3"
PATCH_VERSION: Final = "4"
__short_version__: Final = f"{MAJOR_VERSION}.{MINOR_VERSION}"
__version__: Final = f"{__short_version__}.{PATCH_VERSION}"
REQUIRED_PYTHON_VER: Final[tuple[int, int, int]] = (3, 11, 0)

View file

@ -33,7 +33,7 @@ home-assistant-intents==2024.1.2
httpx==0.26.0
ifaddr==0.2.0
janus==1.0.0
Jinja2==3.1.2
Jinja2==3.1.3
lru-dict==1.3.0
mutagen==1.47.0
orjson==3.9.9

View file

@ -355,10 +355,7 @@ def _include_dir_named_yaml(loader: LoaderType, node: yaml.nodes.Node) -> NodeDi
filename = os.path.splitext(os.path.basename(fname))[0]
if os.path.basename(fname) == SECRET_YAML:
continue
loaded_yaml = load_yaml(fname, loader.secrets)
if loaded_yaml is None:
continue
mapping[filename] = loaded_yaml
mapping[filename] = load_yaml(fname, loader.secrets)
return _add_reference(mapping, loader, node)

View file

@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "homeassistant"
version = "2024.1.3"
version = "2024.1.4"
license = {text = "Apache-2.0"}
description = "Open-source home automation platform running on Python 3."
readme = "README.rst"
@ -39,7 +39,7 @@ dependencies = [
"httpx==0.26.0",
"home-assistant-bluetooth==1.12.0",
"ifaddr==0.2.0",
"Jinja2==3.1.2",
"Jinja2==3.1.3",
"lru-dict==1.3.0",
"PyJWT==2.8.0",
# PyJWT has loose dependency. We want the latest one.

View file

@ -17,7 +17,7 @@ ciso8601==2.3.0
httpx==0.26.0
home-assistant-bluetooth==1.12.0
ifaddr==0.2.0
Jinja2==3.1.2
Jinja2==3.1.3
lru-dict==1.3.0
PyJWT==2.8.0
cryptography==41.0.7

View file

@ -257,7 +257,7 @@ aioguardian==2022.07.0
aioharmony==0.2.10
# homeassistant.components.homekit_controller
aiohomekit==3.1.2
aiohomekit==3.1.3
# homeassistant.components.http
aiohttp-fast-url-dispatcher==0.3.0
@ -344,7 +344,7 @@ aioqsw==0.3.5
aiorecollect==2023.09.0
# homeassistant.components.ridwell
aioridwell==2023.07.0
aioridwell==2024.01.0
# homeassistant.components.ruckus_unleashed
aioruckus==0.34
@ -377,7 +377,7 @@ aiosyncthing==0.5.1
aiotractive==0.5.6
# homeassistant.components.unifi
aiounifi==68
aiounifi==69
# homeassistant.components.vlc_telnet
aiovlc==0.1.0
@ -840,7 +840,7 @@ fjaraskupan==2.2.0
flexit_bacnet==2.1.0
# homeassistant.components.flipr
flipr-api==1.5.0
flipr-api==1.5.1
# homeassistant.components.flux_led
flux-led==1.0.4
@ -1425,7 +1425,7 @@ openhomedevice==2.2.0
opensensemap-api==0.2.0
# homeassistant.components.enigma2
openwebifpy==4.0.4
openwebifpy==4.2.1
# homeassistant.components.luci
openwrt-luci-rpc==1.1.16
@ -1744,7 +1744,7 @@ pyedimax==0.2.1
pyefergy==22.1.1
# homeassistant.components.enphase_envoy
pyenphase==1.15.2
pyenphase==1.17.0
# homeassistant.components.envisalink
pyenvisalink==4.6
@ -2241,7 +2241,7 @@ python-smarttub==0.0.36
python-songpal==0.16
# homeassistant.components.tado
python-tado==0.17.3
python-tado==0.17.4
# homeassistant.components.telegram_bot
python-telegram-bot==13.1
@ -2376,7 +2376,7 @@ renault-api==0.2.1
renson-endura-delta==1.7.1
# homeassistant.components.reolink
reolink-aio==0.8.6
reolink-aio==0.8.7
# homeassistant.components.idteck_prox
rfk101py==0.0.1
@ -2887,7 +2887,7 @@ zigpy-zigate==0.12.0
zigpy-znp==0.12.1
# homeassistant.components.zha
zigpy==0.60.4
zigpy==0.60.6
# homeassistant.components.zoneminder
zm-py==0.5.4

View file

@ -233,7 +233,7 @@ aioguardian==2022.07.0
aioharmony==0.2.10
# homeassistant.components.homekit_controller
aiohomekit==3.1.2
aiohomekit==3.1.3
# homeassistant.components.http
aiohttp-fast-url-dispatcher==0.3.0
@ -317,7 +317,7 @@ aioqsw==0.3.5
aiorecollect==2023.09.0
# homeassistant.components.ridwell
aioridwell==2023.07.0
aioridwell==2024.01.0
# homeassistant.components.ruckus_unleashed
aioruckus==0.34
@ -350,7 +350,7 @@ aiosyncthing==0.5.1
aiotractive==0.5.6
# homeassistant.components.unifi
aiounifi==68
aiounifi==69
# homeassistant.components.vlc_telnet
aiovlc==0.1.0
@ -675,7 +675,7 @@ fjaraskupan==2.2.0
flexit_bacnet==2.1.0
# homeassistant.components.flipr
flipr-api==1.5.0
flipr-api==1.5.1
# homeassistant.components.flux_led
flux-led==1.0.4
@ -1328,7 +1328,7 @@ pyeconet==0.1.22
pyefergy==22.1.1
# homeassistant.components.enphase_envoy
pyenphase==1.15.2
pyenphase==1.17.0
# homeassistant.components.everlights
pyeverlights==0.1.0
@ -1696,7 +1696,7 @@ python-smarttub==0.0.36
python-songpal==0.16
# homeassistant.components.tado
python-tado==0.17.3
python-tado==0.17.4
# homeassistant.components.telegram_bot
python-telegram-bot==13.1
@ -1798,7 +1798,7 @@ renault-api==0.2.1
renson-endura-delta==1.7.1
# homeassistant.components.reolink
reolink-aio==0.8.6
reolink-aio==0.8.7
# homeassistant.components.rflink
rflink==0.0.65
@ -2186,7 +2186,7 @@ zigpy-zigate==0.12.0
zigpy-znp==0.12.1
# homeassistant.components.zha
zigpy==0.60.4
zigpy==0.60.6
# homeassistant.components.zwave_js
zwave-js-server-python==0.55.3

View file

@ -31,6 +31,8 @@ from homeassistant.components.matrix import (
CONF_WORD,
EVENT_MATRIX_COMMAND,
MatrixBot,
RoomAlias,
RoomAnyID,
RoomID,
)
from homeassistant.components.matrix.const import DOMAIN as MATRIX_DOMAIN
@ -51,13 +53,15 @@ from tests.common import async_capture_events
TEST_NOTIFIER_NAME = "matrix_notify"
TEST_HOMESERVER = "example.com"
TEST_DEFAULT_ROOM = "!DefaultNotificationRoom:example.com"
TEST_ROOM_A_ID = "!RoomA-ID:example.com"
TEST_ROOM_B_ID = "!RoomB-ID:example.com"
TEST_ROOM_B_ALIAS = "#RoomB-Alias:example.com"
TEST_JOINABLE_ROOMS = {
TEST_DEFAULT_ROOM = RoomID("!DefaultNotificationRoom:example.com")
TEST_ROOM_A_ID = RoomID("!RoomA-ID:example.com")
TEST_ROOM_B_ID = RoomID("!RoomB-ID:example.com")
TEST_ROOM_B_ALIAS = RoomAlias("#RoomB-Alias:example.com")
TEST_ROOM_C_ID = RoomID("!RoomC-ID:example.com")
TEST_JOINABLE_ROOMS: dict[RoomAnyID, RoomID] = {
TEST_ROOM_A_ID: TEST_ROOM_A_ID,
TEST_ROOM_B_ALIAS: TEST_ROOM_B_ID,
TEST_ROOM_C_ID: TEST_ROOM_C_ID,
}
TEST_BAD_ROOM = "!UninvitedRoom:example.com"
TEST_MXID = "@user:example.com"
@ -74,7 +78,7 @@ class _MockAsyncClient(AsyncClient):
async def close(self):
return None
async def room_resolve_alias(self, room_alias: str):
async def room_resolve_alias(self, room_alias: RoomAnyID):
if room_id := TEST_JOINABLE_ROOMS.get(room_alias):
return RoomResolveAliasResponse(
room_alias=room_alias, room_id=room_id, servers=[TEST_HOMESERVER]
@ -150,6 +154,16 @@ MOCK_CONFIG_DATA = {
CONF_EXPRESSION: "My name is (?P<name>.*)",
CONF_NAME: "ExpressionTriggerEventName",
},
{
CONF_WORD: "WordTriggerSubset",
CONF_NAME: "WordTriggerSubsetEventName",
CONF_ROOMS: [TEST_ROOM_B_ALIAS, TEST_ROOM_C_ID],
},
{
CONF_EXPRESSION: "Your name is (?P<name>.*)",
CONF_NAME: "ExpressionTriggerSubsetEventName",
CONF_ROOMS: [TEST_ROOM_B_ALIAS, TEST_ROOM_C_ID],
},
],
},
NOTIFY_DOMAIN: {
@ -164,15 +178,32 @@ MOCK_WORD_COMMANDS = {
"WordTrigger": {
"word": "WordTrigger",
"name": "WordTriggerEventName",
"rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID],
"rooms": list(TEST_JOINABLE_ROOMS.values()),
}
},
TEST_ROOM_B_ID: {
"WordTrigger": {
"word": "WordTrigger",
"name": "WordTriggerEventName",
"rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID],
}
"rooms": list(TEST_JOINABLE_ROOMS.values()),
},
"WordTriggerSubset": {
"word": "WordTriggerSubset",
"name": "WordTriggerSubsetEventName",
"rooms": [TEST_ROOM_B_ID, TEST_ROOM_C_ID],
},
},
TEST_ROOM_C_ID: {
"WordTrigger": {
"word": "WordTrigger",
"name": "WordTriggerEventName",
"rooms": list(TEST_JOINABLE_ROOMS.values()),
},
"WordTriggerSubset": {
"word": "WordTriggerSubset",
"name": "WordTriggerSubsetEventName",
"rooms": [TEST_ROOM_B_ID, TEST_ROOM_C_ID],
},
},
}
@ -181,15 +212,32 @@ MOCK_EXPRESSION_COMMANDS = {
{
"expression": re.compile("My name is (?P<name>.*)"),
"name": "ExpressionTriggerEventName",
"rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID],
"rooms": list(TEST_JOINABLE_ROOMS.values()),
}
],
TEST_ROOM_B_ID: [
{
"expression": re.compile("My name is (?P<name>.*)"),
"name": "ExpressionTriggerEventName",
"rooms": [TEST_ROOM_A_ID, TEST_ROOM_B_ID],
}
"rooms": list(TEST_JOINABLE_ROOMS.values()),
},
{
"expression": re.compile("Your name is (?P<name>.*)"),
"name": "ExpressionTriggerSubsetEventName",
"rooms": [TEST_ROOM_B_ID, TEST_ROOM_C_ID],
},
],
TEST_ROOM_C_ID: [
{
"expression": re.compile("My name is (?P<name>.*)"),
"name": "ExpressionTriggerEventName",
"rooms": list(TEST_JOINABLE_ROOMS.values()),
},
{
"expression": re.compile("Your name is (?P<name>.*)"),
"name": "ExpressionTriggerSubsetEventName",
"rooms": [TEST_ROOM_B_ID, TEST_ROOM_C_ID],
},
],
}

View file

@ -0,0 +1,180 @@
"""Test MatrixBot's ability to parse and respond to commands in matrix rooms."""
from functools import partial
from itertools import chain
from typing import Any
from nio import MatrixRoom, RoomMessageText
from pydantic.dataclasses import dataclass
import pytest
from homeassistant.components.matrix import MatrixBot, RoomID
from homeassistant.core import Event, HomeAssistant
from tests.components.matrix.conftest import (
MOCK_EXPRESSION_COMMANDS,
MOCK_WORD_COMMANDS,
TEST_MXID,
TEST_ROOM_A_ID,
TEST_ROOM_B_ID,
TEST_ROOM_C_ID,
)
ALL_ROOMS = (TEST_ROOM_A_ID, TEST_ROOM_B_ID, TEST_ROOM_C_ID)
SUBSET_ROOMS = (TEST_ROOM_B_ID, TEST_ROOM_C_ID)
@dataclass
class CommandTestParameters:
"""Dataclass of parameters representing the command config parameters and expected result state.
Switches behavior based on `room_id` and `expected_event_room_data`.
"""
room_id: RoomID
room_message: RoomMessageText
expected_event_data_extra: dict[str, Any] | None
@property
def expected_event_data(self) -> dict[str, Any] | None:
"""Fully-constructed expected event data.
Commands that are named with 'Subset' are expected not to be read from Room A.
"""
if (
self.expected_event_data_extra is None
or "Subset" in self.expected_event_data_extra["command"]
and self.room_id not in SUBSET_ROOMS
):
return None
return {
"sender": "@SomeUser:example.com",
"room": self.room_id,
} | self.expected_event_data_extra
room_message_base = partial(
RoomMessageText,
formatted_body=None,
format=None,
source={
"event_id": "fake_event_id",
"sender": "@SomeUser:example.com",
"origin_server_ts": 123456789,
},
)
word_command_global = partial(
CommandTestParameters,
room_message=room_message_base(body="!WordTrigger arg1 arg2"),
expected_event_data_extra={
"command": "WordTriggerEventName",
"args": ["arg1", "arg2"],
},
)
expr_command_global = partial(
CommandTestParameters,
room_message=room_message_base(body="My name is FakeName"),
expected_event_data_extra={
"command": "ExpressionTriggerEventName",
"args": {"name": "FakeName"},
},
)
word_command_subset = partial(
CommandTestParameters,
room_message=room_message_base(body="!WordTriggerSubset arg1 arg2"),
expected_event_data_extra={
"command": "WordTriggerSubsetEventName",
"args": ["arg1", "arg2"],
},
)
expr_command_subset = partial(
CommandTestParameters,
room_message=room_message_base(body="Your name is FakeName"),
expected_event_data_extra={
"command": "ExpressionTriggerSubsetEventName",
"args": {"name": "FakeName"},
},
)
# Messages without commands should trigger nothing
fake_command_global = partial(
CommandTestParameters,
room_message=room_message_base(body="This is not a real command!"),
expected_event_data_extra=None,
)
# Valid commands sent by the bot user should trigger nothing
self_command_global = partial(
CommandTestParameters,
room_message=room_message_base(
body="!WordTrigger arg1 arg2",
source={
"event_id": "fake_event_id",
"sender": TEST_MXID,
"origin_server_ts": 123456789,
},
),
expected_event_data_extra=None,
)
@pytest.mark.parametrize(
"command_params",
chain(
(word_command_global(room_id) for room_id in ALL_ROOMS),
(expr_command_global(room_id) for room_id in ALL_ROOMS),
(word_command_subset(room_id) for room_id in SUBSET_ROOMS),
(expr_command_subset(room_id) for room_id in SUBSET_ROOMS),
),
)
async def test_commands(
hass: HomeAssistant,
matrix_bot: MatrixBot,
command_events: list[Event],
command_params: CommandTestParameters,
):
"""Test that the configured commands are used correctly."""
room = MatrixRoom(room_id=command_params.room_id, own_user_id=matrix_bot._mx_id)
await hass.async_start()
assert len(command_events) == 0
await matrix_bot._handle_room_message(room, command_params.room_message)
await hass.async_block_till_done()
# MatrixBot should emit exactly one Event with matching data from this Command
assert len(command_events) == 1
event = command_events[0]
assert event.data == command_params.expected_event_data
@pytest.mark.parametrize(
"command_params",
chain(
(word_command_subset(TEST_ROOM_A_ID),),
(expr_command_subset(TEST_ROOM_A_ID),),
(fake_command_global(room_id) for room_id in ALL_ROOMS),
(self_command_global(room_id) for room_id in ALL_ROOMS),
),
)
async def test_non_commands(
hass: HomeAssistant,
matrix_bot: MatrixBot,
command_events: list[Event],
command_params: CommandTestParameters,
):
"""Test that normal/non-qualifying messages don't wrongly trigger commands."""
room = MatrixRoom(room_id=command_params.room_id, own_user_id=matrix_bot._mx_id)
await hass.async_start()
assert len(command_events) == 0
await matrix_bot._handle_room_message(room, command_params.room_message)
await hass.async_block_till_done()
# MatrixBot should not treat this message as a Command
assert len(command_events) == 0
async def test_commands_parsing(hass: HomeAssistant, matrix_bot: MatrixBot):
"""Test that the configured commands were parsed correctly."""
await hass.async_start()
assert matrix_bot._word_commands == MOCK_WORD_COMMANDS
assert matrix_bot._expression_commands == MOCK_EXPRESSION_COMMANDS

View file

@ -1,5 +1,4 @@
"""Configure and test MatrixBot."""
from nio import MatrixRoom, RoomMessageText
from homeassistant.components.matrix import (
DOMAIN as MATRIX_DOMAIN,
@ -9,12 +8,7 @@ from homeassistant.components.matrix import (
from homeassistant.components.notify import DOMAIN as NOTIFY_DOMAIN
from homeassistant.core import HomeAssistant
from .conftest import (
MOCK_EXPRESSION_COMMANDS,
MOCK_WORD_COMMANDS,
TEST_NOTIFIER_NAME,
TEST_ROOM_A_ID,
)
from .conftest import TEST_NOTIFIER_NAME
async def test_services(hass: HomeAssistant, matrix_bot: MatrixBot):
@ -29,61 +23,3 @@ async def test_services(hass: HomeAssistant, matrix_bot: MatrixBot):
# Verify that the matrix notifier is registered
assert (notify_service := services.get(NOTIFY_DOMAIN))
assert TEST_NOTIFIER_NAME in notify_service
async def test_commands(hass, matrix_bot: MatrixBot, command_events):
"""Test that the configured commands were parsed correctly."""
await hass.async_start()
assert len(command_events) == 0
assert matrix_bot._word_commands == MOCK_WORD_COMMANDS
assert matrix_bot._expression_commands == MOCK_EXPRESSION_COMMANDS
room_id = TEST_ROOM_A_ID
room = MatrixRoom(room_id=room_id, own_user_id=matrix_bot._mx_id)
# Test single-word command.
word_command_message = RoomMessageText(
body="!WordTrigger arg1 arg2",
formatted_body=None,
format=None,
source={
"event_id": "fake_event_id",
"sender": "@SomeUser:example.com",
"origin_server_ts": 123456789,
},
)
await matrix_bot._handle_room_message(room, word_command_message)
await hass.async_block_till_done()
assert len(command_events) == 1
event = command_events.pop()
assert event.data == {
"command": "WordTriggerEventName",
"sender": "@SomeUser:example.com",
"room": room_id,
"args": ["arg1", "arg2"],
}
# Test expression command.
room = MatrixRoom(room_id=room_id, own_user_id=matrix_bot._mx_id)
expression_command_message = RoomMessageText(
body="My name is FakeName",
formatted_body=None,
format=None,
source={
"event_id": "fake_event_id",
"sender": "@SomeUser:example.com",
"origin_server_ts": 123456789,
},
)
await matrix_bot._handle_room_message(room, expression_command_message)
await hass.async_block_till_done()
assert len(command_events) == 1
event = command_events.pop()
assert event.data == {
"command": "ExpressionTriggerEventName",
"sender": "@SomeUser:example.com",
"room": room_id,
"args": {"name": "FakeName"},
}

View file

@ -202,6 +202,8 @@ async def test_color_temperature_light(
command=clusters.ColorControl.Commands.MoveToColorTemperature(
colorTemperatureMireds=300,
transitionTime=0,
optionsMask=1,
optionsOverride=1,
),
),
call(
@ -278,7 +280,11 @@ async def test_extended_color_light(
node_id=light_node.node_id,
endpoint_id=1,
command=clusters.ColorControl.Commands.MoveToColor(
colorX=0.5 * 65536, colorY=0.5 * 65536, transitionTime=0
colorX=0.5 * 65536,
colorY=0.5 * 65536,
transitionTime=0,
optionsMask=1,
optionsOverride=1,
),
),
call(
@ -311,8 +317,8 @@ async def test_extended_color_light(
hue=167,
saturation=254,
transitionTime=0,
optionsMask=0,
optionsOverride=0,
optionsMask=1,
optionsOverride=1,
),
),
call(

View file

@ -146,6 +146,29 @@ async def test_climate_set_temperature(
mock_block_device.http_request.assert_called_once_with(
"get", "thermostat/0", {"target_t_enabled": 1, "target_t": "23.0"}
)
mock_block_device.http_request.reset_mock()
# Test conversion from C to F
monkeypatch.setattr(
mock_block_device,
"settings",
{
"thermostats": [
{"target_t": {"units": "F"}},
]
},
)
await hass.services.async_call(
CLIMATE_DOMAIN,
SERVICE_SET_TEMPERATURE,
{ATTR_ENTITY_ID: ENTITY_ID, ATTR_TEMPERATURE: 20},
blocking=True,
)
mock_block_device.http_request.assert_called_once_with(
"get", "thermostat/0", {"target_t_enabled": 1, "target_t": "68.0"}
)
async def test_climate_set_preset_mode(

View file

@ -11,8 +11,12 @@ from aioshelly.exceptions import (
import pytest
from homeassistant.components.shelly.const import (
BLOCK_EXPECTED_SLEEP_PERIOD,
BLOCK_WRONG_SLEEP_PERIOD,
CONF_BLE_SCANNER_MODE,
CONF_SLEEP_PERIOD,
DOMAIN,
MODELS_WITH_WRONG_SLEEP_PERIOD,
BLEScannerMode,
)
from homeassistant.config_entries import SOURCE_REAUTH, ConfigEntryState
@ -309,3 +313,17 @@ async def test_entry_missing_gen(hass: HomeAssistant, mock_block_device) -> None
assert entry.state is ConfigEntryState.LOADED
assert hass.states.get("switch.test_name_channel_1").state is STATE_ON
@pytest.mark.parametrize(("model"), MODELS_WITH_WRONG_SLEEP_PERIOD)
async def test_sleeping_block_device_wrong_sleep_period(
hass: HomeAssistant, mock_block_device, model
) -> None:
"""Test sleeping block device with wrong sleep period."""
entry = await init_integration(
hass, 1, model=model, sleep_period=BLOCK_WRONG_SLEEP_PERIOD, skip_setup=True
)
assert entry.data[CONF_SLEEP_PERIOD] == BLOCK_WRONG_SLEEP_PERIOD
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert entry.data[CONF_SLEEP_PERIOD] == BLOCK_EXPECTED_SLEEP_PERIOD

View file

@ -6,9 +6,15 @@ from homeassistant.components.homeassistant import (
DOMAIN as HA_DOMAIN,
SERVICE_UPDATE_ENTITY,
)
from homeassistant.components.sensor import DOMAIN as SENSOR_DOMAIN
from homeassistant.components.sensor import (
ATTR_STATE_CLASS,
DOMAIN as SENSOR_DOMAIN,
SensorDeviceClass,
SensorStateClass,
)
from homeassistant.components.shelly.const import DOMAIN
from homeassistant.const import (
ATTR_DEVICE_CLASS,
ATTR_ENTITY_ID,
ATTR_UNIT_OF_MEASUREMENT,
PERCENTAGE,
@ -153,7 +159,11 @@ async def test_block_restored_sleeping_sensor(
await hass.config_entries.async_setup(entry.entry_id)
await hass.async_block_till_done()
assert hass.states.get(entity_id).state == "20.4"
state = hass.states.get(entity_id)
assert state
assert state.state == "20.4"
assert state.attributes[ATTR_STATE_CLASS] == SensorStateClass.MEASUREMENT
assert state.attributes[ATTR_DEVICE_CLASS] == SensorDeviceClass.TEMPERATURE
# Make device online
monkeypatch.setattr(mock_block_device, "initialized", True)
@ -237,7 +247,9 @@ async def test_block_not_matched_restored_sleeping_sensor(
assert hass.states.get(entity_id).state == "20.4"
# Make device online
monkeypatch.setattr(mock_block_device.blocks[SENSOR_BLOCK_ID], "type", "other_type")
monkeypatch.setattr(
mock_block_device.blocks[SENSOR_BLOCK_ID], "description", "other_desc"
)
monkeypatch.setattr(mock_block_device, "initialized", True)
mock_block_device.mock_update()
await hass.async_block_till_done()

View file

@ -6,7 +6,7 @@ from unittest.mock import patch
from aiohttp import ClientConnectionError, ClientResponseError
from aiohttp.client import RequestInfo
from homeassistant.components.tessie.const import DOMAIN
from homeassistant.components.tessie.const import DOMAIN, TessieStatus
from homeassistant.const import CONF_ACCESS_TOKEN
from homeassistant.core import HomeAssistant
@ -14,7 +14,9 @@ from tests.common import MockConfigEntry, load_json_object_fixture
TEST_STATE_OF_ALL_VEHICLES = load_json_object_fixture("vehicles.json", DOMAIN)
TEST_VEHICLE_STATE_ONLINE = load_json_object_fixture("online.json", DOMAIN)
TEST_VEHICLE_STATE_ASLEEP = load_json_object_fixture("asleep.json", DOMAIN)
TEST_VEHICLE_STATUS_AWAKE = {"status": TessieStatus.AWAKE}
TEST_VEHICLE_STATUS_ASLEEP = {"status": TessieStatus.ASLEEP}
TEST_RESPONSE = {"result": True}
TEST_RESPONSE_ERROR = {"result": False, "reason": "reason why"}

View file

@ -5,7 +5,11 @@ from unittest.mock import patch
import pytest
from .common import TEST_STATE_OF_ALL_VEHICLES, TEST_VEHICLE_STATE_ONLINE
from .common import (
TEST_STATE_OF_ALL_VEHICLES,
TEST_VEHICLE_STATE_ONLINE,
TEST_VEHICLE_STATUS_AWAKE,
)
@pytest.fixture
@ -18,6 +22,16 @@ def mock_get_state():
yield mock_get_state
@pytest.fixture
def mock_get_status():
"""Mock get_status function."""
with patch(
"homeassistant.components.tessie.coordinator.get_status",
return_value=TEST_VEHICLE_STATUS_AWAKE,
) as mock_get_status:
yield mock_get_status
@pytest.fixture
def mock_get_state_of_all_vehicles():
"""Mock get_state_of_all_vehicles function."""

View file

@ -10,8 +10,7 @@ from .common import (
ERROR_AUTH,
ERROR_CONNECTION,
ERROR_UNKNOWN,
TEST_VEHICLE_STATE_ASLEEP,
TEST_VEHICLE_STATE_ONLINE,
TEST_VEHICLE_STATUS_ASLEEP,
setup_platform,
)
@ -20,59 +19,61 @@ from tests.common import async_fire_time_changed
WAIT = timedelta(seconds=TESSIE_SYNC_INTERVAL)
async def test_coordinator_online(hass: HomeAssistant, mock_get_state) -> None:
async def test_coordinator_online(
hass: HomeAssistant, mock_get_state, mock_get_status
) -> None:
"""Tests that the coordinator handles online vehicles."""
mock_get_state.return_value = TEST_VEHICLE_STATE_ONLINE
await setup_platform(hass)
async_fire_time_changed(hass, utcnow() + WAIT)
await hass.async_block_till_done()
mock_get_status.assert_called_once()
mock_get_state.assert_called_once()
assert hass.states.get("binary_sensor.test_status").state == STATE_ON
async def test_coordinator_asleep(hass: HomeAssistant, mock_get_state) -> None:
async def test_coordinator_asleep(hass: HomeAssistant, mock_get_status) -> None:
"""Tests that the coordinator handles asleep vehicles."""
mock_get_state.return_value = TEST_VEHICLE_STATE_ASLEEP
await setup_platform(hass)
mock_get_status.return_value = TEST_VEHICLE_STATUS_ASLEEP
async_fire_time_changed(hass, utcnow() + WAIT)
await hass.async_block_till_done()
mock_get_state.assert_called_once()
mock_get_status.assert_called_once()
assert hass.states.get("binary_sensor.test_status").state == STATE_OFF
async def test_coordinator_clienterror(hass: HomeAssistant, mock_get_state) -> None:
async def test_coordinator_clienterror(hass: HomeAssistant, mock_get_status) -> None:
"""Tests that the coordinator handles client errors."""
mock_get_state.side_effect = ERROR_UNKNOWN
mock_get_status.side_effect = ERROR_UNKNOWN
await setup_platform(hass)
async_fire_time_changed(hass, utcnow() + WAIT)
await hass.async_block_till_done()
mock_get_state.assert_called_once()
mock_get_status.assert_called_once()
assert hass.states.get("binary_sensor.test_status").state == STATE_UNAVAILABLE
async def test_coordinator_auth(hass: HomeAssistant, mock_get_state) -> None:
async def test_coordinator_auth(hass: HomeAssistant, mock_get_status) -> None:
"""Tests that the coordinator handles timeout errors."""
mock_get_state.side_effect = ERROR_AUTH
mock_get_status.side_effect = ERROR_AUTH
await setup_platform(hass)
async_fire_time_changed(hass, utcnow() + WAIT)
await hass.async_block_till_done()
mock_get_state.assert_called_once()
mock_get_status.assert_called_once()
async def test_coordinator_connection(hass: HomeAssistant, mock_get_state) -> None:
async def test_coordinator_connection(hass: HomeAssistant, mock_get_status) -> None:
"""Tests that the coordinator handles connection errors."""
mock_get_state.side_effect = ERROR_CONNECTION
mock_get_status.side_effect = ERROR_CONNECTION
await setup_platform(hass)
async_fire_time_changed(hass, utcnow() + WAIT)
await hass.async_block_till_done()
mock_get_state.assert_called_once()
mock_get_status.assert_called_once()
assert hass.states.get("binary_sensor.test_status").state == STATE_UNAVAILABLE

View file

@ -135,7 +135,7 @@ def _wrap_mock_instance(obj: Any) -> MagicMock:
real_attr = getattr(obj, attr_name)
mock_attr = getattr(mock, attr_name)
if callable(real_attr):
if callable(real_attr) and not hasattr(real_attr, "__aenter__"):
mock_attr.side_effect = real_attr
else:
setattr(mock, attr_name, real_attr)

View file

@ -1,12 +1,14 @@
"""Test ZHA Gateway."""
import asyncio
from unittest.mock import MagicMock, patch
from unittest.mock import MagicMock, PropertyMock, patch
import pytest
from zigpy.application import ControllerApplication
import zigpy.profiles.zha as zha
import zigpy.types
import zigpy.zcl.clusters.general as general
import zigpy.zcl.clusters.lighting as lighting
import zigpy.zdo.types
from homeassistant.components.zha.core.gateway import ZHAGateway
from homeassistant.components.zha.core.group import GroupMember
@ -291,3 +293,111 @@ async def test_gateway_force_multi_pan_channel(
_, config = zha_gateway.get_application_controller_data()
assert config["network"]["channel"] == expected_channel
async def test_single_reload_on_multiple_connection_loss(
hass: HomeAssistant,
zigpy_app_controller: ControllerApplication,
config_entry: MockConfigEntry,
):
"""Test that we only reload once when we lose the connection multiple times."""
config_entry.add_to_hass(hass)
zha_gateway = ZHAGateway(hass, {}, config_entry)
with patch(
"bellows.zigbee.application.ControllerApplication.new",
return_value=zigpy_app_controller,
):
await zha_gateway.async_initialize()
with patch.object(
hass.config_entries, "async_reload", wraps=hass.config_entries.async_reload
) as mock_reload:
zha_gateway.connection_lost(RuntimeError())
zha_gateway.connection_lost(RuntimeError())
zha_gateway.connection_lost(RuntimeError())
zha_gateway.connection_lost(RuntimeError())
zha_gateway.connection_lost(RuntimeError())
assert len(mock_reload.mock_calls) == 1
await hass.async_block_till_done()
@pytest.mark.parametrize("radio_concurrency", [1, 2, 8])
async def test_startup_concurrency_limit(
radio_concurrency: int,
hass: HomeAssistant,
zigpy_app_controller: ControllerApplication,
config_entry: MockConfigEntry,
zigpy_device_mock,
):
"""Test ZHA gateway limits concurrency on startup."""
config_entry.add_to_hass(hass)
zha_gateway = ZHAGateway(hass, {}, config_entry)
with patch(
"bellows.zigbee.application.ControllerApplication.new",
return_value=zigpy_app_controller,
):
await zha_gateway.async_initialize()
for i in range(50):
zigpy_dev = zigpy_device_mock(
{
1: {
SIG_EP_INPUT: [
general.OnOff.cluster_id,
general.LevelControl.cluster_id,
lighting.Color.cluster_id,
general.Groups.cluster_id,
],
SIG_EP_OUTPUT: [],
SIG_EP_TYPE: zha.DeviceType.COLOR_DIMMABLE_LIGHT,
SIG_EP_PROFILE: zha.PROFILE_ID,
}
},
ieee=f"11:22:33:44:{i:08x}",
nwk=0x1234 + i,
)
zigpy_dev.node_desc.mac_capability_flags |= (
zigpy.zdo.types.NodeDescriptor.MACCapabilityFlags.MainsPowered
)
zha_gateway._async_get_or_create_device(zigpy_dev, restored=True)
# Keep track of request concurrency during initialization
current_concurrency = 0
concurrencies = []
async def mock_send_packet(*args, **kwargs):
nonlocal current_concurrency
current_concurrency += 1
concurrencies.append(current_concurrency)
await asyncio.sleep(0.001)
current_concurrency -= 1
concurrencies.append(current_concurrency)
type(zha_gateway).radio_concurrency = PropertyMock(return_value=radio_concurrency)
assert zha_gateway.radio_concurrency == radio_concurrency
with patch(
"homeassistant.components.zha.core.device.ZHADevice.async_initialize",
side_effect=mock_send_packet,
):
await zha_gateway.async_fetch_updated_state_mains()
await zha_gateway.shutdown()
# Make sure concurrency was always limited
assert current_concurrency == 0
assert min(concurrencies) == 0
if radio_concurrency > 1:
assert 1 <= max(concurrencies) < zha_gateway.radio_concurrency
else:
assert 1 == max(concurrencies) == zha_gateway.radio_concurrency

View file

@ -193,7 +193,7 @@ def test_include_dir_list_recursive(
),
(
{"/test/first.yaml": "1", "/test/second.yaml": None},
{"first": 1},
{"first": 1, "second": None},
),
],
)