2024.1.4 (#108379)
This commit is contained in:
commit
6e6a5ff52c
52 changed files with 686 additions and 306 deletions
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -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."]
|
||||
}
|
||||
|
|
|
@ -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, {})
|
||||
|
|
|
@ -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,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
|
@ -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"
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"]
|
||||
}
|
||||
|
|
|
@ -7,5 +7,5 @@
|
|||
"integration_type": "service",
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["aioridwell"],
|
||||
"requirements": ["aioridwell==2023.07.0"]
|
||||
"requirements": ["aioridwell==2024.01.0"]
|
||||
}
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -14,5 +14,5 @@
|
|||
},
|
||||
"iot_class": "cloud_polling",
|
||||
"loggers": ["PyTado"],
|
||||
"requirements": ["python-tado==0.17.3"]
|
||||
"requirements": ["python-tado==0.17.4"]
|
||||
}
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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."""
|
||||
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -8,7 +8,7 @@
|
|||
"iot_class": "local_push",
|
||||
"loggers": ["aiounifi"],
|
||||
"quality_scale": "platinum",
|
||||
"requirements": ["aiounifi==68"],
|
||||
"requirements": ["aiounifi==69"],
|
||||
"ssdp": [
|
||||
{
|
||||
"manufacturer": "Ubiquiti Networks",
|
||||
|
|
|
@ -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:
|
||||
|
|
|
@ -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")
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
||||
|
|
|
@ -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",
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
||||
|
||||
|
|
|
@ -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.
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
|
180
tests/components/matrix/test_commands.py
Normal file
180
tests/components/matrix/test_commands.py
Normal 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
|
|
@ -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"},
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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"}
|
||||
|
||||
|
|
|
@ -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."""
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -193,7 +193,7 @@ def test_include_dir_list_recursive(
|
|||
),
|
||||
(
|
||||
{"/test/first.yaml": "1", "/test/second.yaml": None},
|
||||
{"first": 1},
|
||||
{"first": 1, "second": None},
|
||||
),
|
||||
],
|
||||
)
|
||||
|
|
Loading…
Add table
Reference in a new issue